diff --git a/docs/tools/compress-pdf.md b/docs/tools/compress-pdf.md
index 84bc10b8..953f8693 100644
--- a/docs/tools/compress-pdf.md
+++ b/docs/tools/compress-pdf.md
@@ -1,69 +1,73 @@
---
title: Compress PDF
-description: Reduce PDF file size by up to 90% with smart compression algorithms. Supports batch processing and custom quality settings.
+description: Reduce PDF file size by up to 90% with the Hyper Compress engine. Supports target file sizes, batch processing, and custom quality settings.
---
# Compress PDF
-Shrink your PDF files without destroying them. BentoPDF offers two distinct compression algorithms -- one for text-heavy documents, another for photo-heavy scans -- so you get meaningful size reduction regardless of what your PDF contains.
+Shrink your PDF files without destroying them. Compress PDF is powered by [Hyper Compress](https://www.bentopdf.com/hyper-compress), BentoPDF's own compression engine. It recompresses images, subsets and merges fonts, and strips dead weight such as embedded thumbnails and private application data. The PDF stays fully functional: text remains selectable, links and form fields keep working.
+
+Hyper Compress is compiled to WebAssembly and ships with BentoPDF, so compression works offline and in air-gapped deployments with no extra setup.
## How It Works
1. Upload one or more PDFs by clicking the drop zone or dragging files in.
-2. Select a **Compression Algorithm** -- Condense or Photon.
-3. Pick a **Compression Level** from Light to Extreme.
-4. Optionally toggle **Convert to Grayscale** or expand **Custom Settings** for fine-grained control.
-5. Click **Compress PDF**. For multiple files, you get a single ZIP download.
+2. Pick a **Compression Level**: Low, Medium, High, or Lossless.
+3. Optionally enter a **Target File Size**, or expand **Custom Settings** for fine-grained control, including **Convert to grayscale**.
+4. Click **Compress PDF**. For multiple files, you get a single ZIP download.
-## Compression Algorithms
+## Special Documents
-### Condense (Recommended)
-
-The default algorithm. It removes dead weight from the PDF structure, recompresses images at a target DPI, subsets fonts to strip unused glyphs, and removes embedded thumbnails. The PDF stays fully functional -- text remains selectable, links keep working.
-
-Condense requires the PyMuPDF WASM engine.
-
-### Photon (For Photo-Heavy PDFs)
-
-Photon renders each page to a JPEG image and reassembles them into a new PDF. This works well for scanned documents or image-heavy files where Condense cannot squeeze much out. The trade-off is that text becomes non-selectable and hyperlinks stop working.
+- **Digitally signed PDFs** are returned unchanged, because any change would invalidate the signature. Turn on **Override content protection** under Custom Settings to remove the signatures and compress them; the visible signature stays on the page, but the file is no longer signed.
+- **PDF/A files** lose their PDF/A label after compression, so the file never claims a conformance it no longer meets. Turn on **Preserve PDF/A** under Custom Settings to keep the file conformant instead.
+- **Password-protected PDFs** get a password field on their file card. Enter the password there and Hyper Compress opens the file itself, no separate unlock step. With two or more locked files, one shared field unlocks every file that uses the same password. The compressed copy has no password; use [Protect PDF](./protect-pdf) to add one again.
+- **Scans with no text layer** can shrink further with **Rasterize pages** under Custom Settings, which replaces each page with one optimized image. It is skipped for any document that has selectable text, including OCR'd scans, unless **Override content protection** is on. Then text pages become images too, so text stops being selectable and links stop working.
## Compression Levels
-| Level | Image Quality | DPI Target | Best For |
-| -------------- | ------------- | ---------- | ------------------------------ |
-| **Light** | 90% | 150 | Preserving visual quality |
-| **Balanced** | 75% | 96 | Everyday documents |
-| **Aggressive** | 50% | 72 | Email attachments, web uploads |
-| **Extreme** | 30% | 60 | Maximum size reduction |
+| Level | Image Quality | Max Resolution | Best For |
+| ------------ | ---------------- | -------------- | ------------------------------ |
+| **Low** | 80 | 200 DPI | Preserving visual quality |
+| **Medium** | 50 | 150 DPI | Everyday documents |
+| **High** | 20 | 72 DPI | Email attachments, web uploads |
+| **Lossless** | Images untouched | Unchanged | Archival copies |
+
+High also unembeds the 14 standard PDF fonts, plus look-alikes such as Arial when that is safe.
+
+## Target File Size
+
+Enter a size in KB or MB and Hyper Compress searches for the best image quality that still fits, starting from the level you picked. If the document cannot get that small, you get the smallest version it can make and a message saying so. The dedicated pages such as [Compress PDF to 1MB](https://www.bentopdf.com/compress-pdf-to-1mb) open with the target already filled in.
## Custom Settings
-Expand the Custom Settings panel to override preset values:
+Expand the Custom Settings panel to adjust Hyper Compress directly. Choosing a compression level resets these to that level's values, and editing any of them switches the level to Custom.
-- **Output Quality** (1-100%) -- JPEG quality for recompressed images
-- **Resize Images To** (DPI) -- target resolution for downsampled images
-- **Only Process Above** (DPI) -- images below this threshold are left untouched
-- **Remove metadata** -- strips document info fields
-- **Subset fonts** -- removes unused glyphs from embedded fonts
-- **Remove embedded thumbnails** -- drops preview images stored inside the PDF
+- **Override content protection**: by default Hyper Compress leaves signed PDFs untouched and won't rasterize pages that have text. Turning this on bypasses both. It stays on when you change the level.
+
+- **Images**: image quality, maximum resolution, forced resampling, color reduction, clipping invisible image areas, removing alternate images, simplifying color profiles, JPEG 2000, and lossless mode
+- **Fonts**: subsetting, merging duplicates, and unembedding standard fonts and their look-alikes
+- **Content**: flattening annotations, form fields, or links
+- **Cleanup**: removing thumbnails, application data, article threads, web capture information, the structure tree, or output intents
+- **Rasterize**: replacing pages with images, for image-only scans; skipped when the document has selectable text
+- **Output**: Preserve PDF/A, and Brotli compression for PDF 2.0 readers that support it
## Use Cases
- Bringing a 50 MB report under an email attachment limit
+- Getting a scan under a portal's strict upload limit with a target file size
- Optimizing marketing PDFs for faster website downloads
- Archiving large document sets where storage costs matter
-- Preparing scanned contracts for a document management system
- Reducing mobile data usage when sharing PDFs over messaging apps
## Tips
-- Start with Balanced. If the result is still too large, step up to Aggressive before trying Extreme.
-- Use Photon only when Condense produces minimal savings -- it permanently removes text selection.
-- The grayscale toggle alone can cut file size substantially for color-heavy documents.
+- Start with Medium. If the result is still too large, set a target file size or step up to High.
+- For a scan with no text layer, turn on Rasterize pages under Custom Settings.
+- Convert to grayscale, under Custom Settings, can cut file size for color-heavy documents.
## Related Tools
-- [Linearize PDF](./linearize-pdf) -- optimize the compressed PDF for fast web viewing
-- [Rasterize PDF](./rasterize-pdf) -- convert pages to images at a specific DPI
-- [Repair PDF](./repair-pdf) -- fix a PDF that broke during compression elsewhere
-- [Remove Metadata](./remove-metadata) -- strip hidden data for privacy
+- [Linearize PDF](./linearize-pdf): optimize the compressed PDF for fast web viewing
+- [Rasterize PDF](./rasterize-pdf): convert pages to images at a specific DPI, including pages with text
+- [Repair PDF](./repair-pdf): fix a PDF that broke during compression elsewhere
+- [Remove Metadata](./remove-metadata): strip hidden data for privacy
diff --git a/package-lock.json b/package-lock.json
index 13c6b9d1..62789ce5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bento-pdf",
- "version": "2.8.9",
+ "version": "2.8.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bento-pdf",
- "version": "2.8.9",
+ "version": "2.8.10",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource/cedarville-cursive": "^5.2.7",
@@ -38,6 +38,7 @@
"heic2any": "^0.0.4",
"highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
+ "hyper-compress-wasm": "file:vendor/hyper-compress-wasm/hyper-compress-wasm-1.0.3.tgz",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.6",
@@ -7022,6 +7023,15 @@
"url": "https://github.com/sponsors/typicode"
}
},
+ "node_modules/hyper-compress-wasm": {
+ "version": "1.0.3",
+ "resolved": "file:vendor/hyper-compress-wasm/hyper-compress-wasm-1.0.3.tgz",
+ "integrity": "sha512-utOyNU/cTtMlCe+ik1ggYcuvINy8K4nDBHMA7VVeKTQlM0tWMJVWvY93R345sNOa49tr3nEeCDgGoNpp43ZQUQ==",
+ "license": "AGPL-3.0-only",
+ "engines": {
+ "node": ">=22"
+ }
+ },
"node_modules/i18next": {
"version": "25.10.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.2.tgz",
diff --git a/package.json b/package.json
index 6a3e5a20..5a306a39 100644
--- a/package.json
+++ b/package.json
@@ -103,6 +103,7 @@
"heic2any": "^0.0.4",
"highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
+ "hyper-compress-wasm": "file:vendor/hyper-compress-wasm/hyper-compress-wasm-1.0.3.tgz",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.6",
diff --git a/pdf-editor.html b/pdf-editor.html
index ee52abf2..a0c6ad2d 100644
--- a/pdf-editor.html
+++ b/pdf-editor.html
@@ -346,12 +346,10 @@
class="text-indigo-300 hover:text-indigo-400"
>Compress PDF
- offers two methods. Condense strips unused structure, downsamples
- images to a target DPI, and subsets fonts while keeping text
- selectable and links working; Photon re-renders each page as a
- JPEG, which saves more on scans and photo-heavy files but leaves
- text unselectable. Start with the Balanced level and only step up
- to Aggressive or Extreme if the file is still too large.
+ strips unused structure, downsamples images, and subsets fonts
+ while keeping text selectable and links working. Start with the
+ Medium level, or enter a target file size, and only step up to
+ High if the file is still too large.
@@ -364,9 +362,8 @@
Background Color, and Remove Annotations work on the PDF structure
and leave text searchable. Invert Colors, Change Text Color, and
PDF to Greyscale re-render every page as an image, so text in
- their output can no longer be selected or searched, and the Photon
- compression mode does the same. If you need searchable text after
- one of those, run the result through
+ their output can no longer be selected or searched. If you need
+ searchable text after one of those, run the result through
OCR PDF.
diff --git a/public/locales/ar/tools.json b/public/locales/ar/tools.json
index d9f78c38..bbe24e44 100644
--- a/public/locales/ar/tools.json
+++ b/public/locales/ar/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "ضغط PDF",
- "subtitle": "تقليل حجم ملف PDF الخاص بك.",
- "algorithmLabel": "خوارزمية الضغط",
- "condense": "تكثيف (موصى به)",
- "photon": "فوتون (لملفات PDF كثيرة الصور)",
- "condenseInfo": "يستخدم التكثيف ضغطًا متقدمًا: يزيل البيانات الزائدة، يحسّن الصور، يقلّص الخطوط. الأفضل لمعظم ملفات PDF.",
- "photonInfo": "يحول فوتون الصفحات إلى صور. استخدمه لملفات PDF كثيرة الصور/الممسوحة ضوئيًا.",
- "photonWarning": "تحذير: سيصبح النص غير قابل للتحديد وستتوقف الروابط عن العمل.",
- "levelLabel": "مستوى الضغط",
- "light": "خفيف (الحفاظ على الجودة)",
- "balanced": "متوازن (موصى به)",
- "aggressive": "عدواني (ملفات أصغر)",
- "extreme": "أقصى (ضغط أقصى)",
- "grayscale": "تحويل إلى تدرج الرمادي",
- "grayscaleHint": "يقلل حجم الملف بإزالة معلومات الألوان",
- "customSettings": "إعدادات مخصصة",
- "customSettingsHint": "ضبط دقيق لمعلمات الضغط:",
- "outputQuality": "جودة المخرجات",
- "resizeImagesTo": "تغيير حجم الصور إلى",
- "onlyProcessAbove": "معالجة فقط أعلى من",
- "removeMetadata": "إزالة البيانات الوصفية",
- "subsetFonts": "تقليص الخطوط (إزالة الحروف غير المستخدمة)",
- "removeThumbnails": "إزالة الصور المصغرة المضمنة",
- "compressButton": "ضغط PDF"
+ "subtitle": "تقليل حجم ملف PDF الخاص بك."
},
"pdfEditor": {
"name": "محرر PDF",
diff --git a/public/locales/be/tools.json b/public/locales/be/tools.json
index b8c31892..1aa14b54 100644
--- a/public/locales/be/tools.json
+++ b/public/locales/be/tools.json
@@ -28,29 +28,7 @@
},
"compressPdf": {
"name": "Сціснуць PDF",
- "subtitle": "Зменшыць памер файла PDF.",
- "algorithmLabel": "Алгарытм сціскання",
- "condense": "Condense (Рэкамендуецца)",
- "photon": "Photon (Для PDF з вялікай колькасцю фота)",
- "condenseInfo": "Condense выкарыстоўвае прасунутае сцісканне: выдаляе лішняе, аптымізуе відарысы, паднаборы шрыфтоў. Найлепш пасуе для большасці PDF.",
- "photonInfo": "Photon канвертуе старонкі ў відарысы. Для PDF з вялікай колькасцю фота або сканаваных PDF.",
- "photonWarning": "Папярэджанне: стане немагчыма вылучыць тэкст, і перастануць працаваць спасылкі.",
- "levelLabel": "Узровень сціскання",
- "light": "Лёгкі (Захаванне якасці)",
- "balanced": "Збалансаваны (Рэкамендуецца)",
- "aggressive": "Агрэсіўны (Меншыя файлы)",
- "extreme": "Экстрэмальны (Максімальнае сцісканне)",
- "grayscale": "Канвертаваць у градацыі шэрага",
- "grayscaleHint": "Памяншае памер файла, выдаляючы інфармацыю пра колер",
- "customSettings": "Карыстальніцкія налады",
- "customSettingsHint": "Дакладная настройка параметраў сціскання:",
- "outputQuality": "Якасць вываду",
- "resizeImagesTo": "Змяніць памер відарысаў да",
- "onlyProcessAbove": "Апрацоўваць толькі большыя за",
- "removeMetadata": "Выдаліць метаданыя",
- "subsetFonts": "Паднабор шрыфтоў (выдаліць сімвалы, якія не выкарыстоўваюцца)",
- "removeThumbnails": "Выдаліць убудаваныя мініяцюры",
- "compressButton": "Сціснуць PDF"
+ "subtitle": "Зменшыць памер файла PDF."
},
"pdfEditor": {
"name": "Рэдактар PDF",
diff --git a/public/locales/da/tools.json b/public/locales/da/tools.json
index 52277ccc..31791d3e 100644
--- a/public/locales/da/tools.json
+++ b/public/locales/da/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Komprimér PDF",
- "subtitle": "Reducer filstørrelsen på din PDF.",
- "algorithmLabel": "Komprimeringsalgoritme",
- "condense": "Kondenser (anbefalet)",
- "photon": "Photon (til billedtunge PDF’er)",
- "condenseInfo": "Kondenser bruger avanceret komprimering: fjerner overflødigt indhold, optimerer billeder, udvælger skrifttyper. Bedst til de fleste PDF’er.",
- "photonInfo": "Photon konverterer sider til billeder. Bruges til billedtunge eller scannede PDF’er.",
- "photonWarning": "Advarsel: Tekst bliver ikke valgbar, og links holder op med at virke.",
- "levelLabel": "Komprimeringsniveau",
- "light": "Let (bevar kvalitet)",
- "balanced": "Balanceret (anbefalet)",
- "aggressive": "Aggressiv (mindre filer)",
- "extreme": "Ekstrem (maksimal komprimering)",
- "grayscale": "Konverter til gråtoner",
- "grayscaleHint": "Reducerer filstørrelsen ved at fjerne farveinformation",
- "customSettings": "Brugerdefinerede indstillinger",
- "customSettingsHint": "Finjustér komprimeringsparametre:",
- "outputQuality": "Outputkvalitet",
- "resizeImagesTo": "Skalér billeder til",
- "onlyProcessAbove": "Behandl kun over",
- "removeMetadata": "Fjern metadata",
- "subsetFonts": "Delvis skrifttypeindlæsning (fjern ubrugte tegn)",
- "removeThumbnails": "Fjern indlejrede miniaturer",
- "compressButton": "Komprimér PDF"
+ "subtitle": "Reducer filstørrelsen på din PDF."
},
"pdfEditor": {
"name": "PDF-editor",
diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json
index 5c6cfa46..dda99cf7 100644
--- a/public/locales/de/tools.json
+++ b/public/locales/de/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "PDF komprimieren",
- "subtitle": "Die Dateigröße Ihrer PDF reduzieren.",
- "algorithmLabel": "Komprimierungsalgorithmus",
- "condense": "Condense (Empfohlen)",
- "photon": "Photon (Für bildlastige PDFs)",
- "condenseInfo": "Condense nutzt fortschrittliche Komprimierung: entfernt unnötige Daten, optimiert Bilder, reduziert Schriftarten. Optimal für die meisten PDFs.",
- "photonInfo": "Photon wandelt Seiten in Bilder um. Für bildlastige/gescannte PDFs.",
- "photonWarning": "Warnung: Text wird nicht mehr auswählbar und Links funktionieren nicht mehr.",
- "levelLabel": "Komprimierungsstufe",
- "light": "Leicht (Qualität erhalten)",
- "balanced": "Ausgewogen (Empfohlen)",
- "aggressive": "Aggressiv (Kleinere Dateien)",
- "extreme": "Extrem (Maximale Komprimierung)",
- "grayscale": "In Graustufen umwandeln",
- "grayscaleHint": "Reduziert Dateigröße durch Entfernen von Farbinformationen",
- "customSettings": "Erweiterte Einstellungen",
- "customSettingsHint": "Komprimierungsparameter anpassen:",
- "outputQuality": "Ausgabequalität",
- "resizeImagesTo": "Bilder anpassen auf",
- "onlyProcessAbove": "Nur verarbeiten über",
- "removeMetadata": "Metadaten entfernen",
- "subsetFonts": "Schriftarten optimieren (ungenutzte Zeichen entfernen)",
- "removeThumbnails": "Eingebettete Miniaturansichten entfernen",
- "compressButton": "PDF komprimieren"
+ "subtitle": "Die Dateigröße Ihrer PDF reduzieren."
},
"pdfEditor": {
"name": "PDF-Editor",
diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json
index 4976b13d..d34e9c00 100644
--- a/public/locales/en/tools.json
+++ b/public/locales/en/tools.json
@@ -28,29 +28,7 @@
},
"compressPdf": {
"name": "Compress PDF",
- "subtitle": "Reduce the file size of your PDF.",
- "algorithmLabel": "Compression Algorithm",
- "condense": "Condense (Recommended)",
- "photon": "Photon (For Photo-Heavy PDFs)",
- "condenseInfo": "Condense uses advanced compression: removes dead-weight, optimizes images, subsets fonts. Best for most PDFs.",
- "photonInfo": "Photon converts pages to images. Use for photo-heavy/scanned PDFs.",
- "photonWarning": "Warning: Text will become non-selectable and links will stop working.",
- "levelLabel": "Compression Level",
- "light": "Light (Preserve Quality)",
- "balanced": "Balanced (Recommended)",
- "aggressive": "Aggressive (Smaller Files)",
- "extreme": "Extreme (Maximum Compression)",
- "grayscale": "Convert to Grayscale",
- "grayscaleHint": "Reduces file size by removing color information",
- "customSettings": "Custom Settings",
- "customSettingsHint": "Fine-tune compression parameters:",
- "outputQuality": "Output Quality",
- "resizeImagesTo": "Resize Images To",
- "onlyProcessAbove": "Only Process Above",
- "removeMetadata": "Remove metadata",
- "subsetFonts": "Subset fonts (remove unused glyphs)",
- "removeThumbnails": "Remove embedded thumbnails",
- "compressButton": "Compress PDF"
+ "subtitle": "Reduce the file size of your PDF."
},
"pdfEditor": {
"name": "PDF Editor",
diff --git a/public/locales/es/tools.json b/public/locales/es/tools.json
index ddbe8db9..ea20b951 100644
--- a/public/locales/es/tools.json
+++ b/public/locales/es/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Comprimir PDF",
- "subtitle": "Reduce el tamaño de archivo de tu PDF.",
- "algorithmLabel": "Algoritmo de Compresión",
- "condense": "Condensar (Recomendado)",
- "photon": "Photon (Para PDFs con Muchas Fotos)",
- "condenseInfo": "Condensar usa compresión avanzada: elimina peso muerto, optimiza imágenes, reduce fuentes. Mejor para la mayoría de PDFs.",
- "photonInfo": "Photon convierte páginas en imágenes. Úsalo para PDFs con muchas fotos/escaneados.",
- "photonWarning": "Advertencia: El texto dejará de ser seleccionable y los enlaces dejarán de funcionar.",
- "levelLabel": "Nivel de Compresión",
- "light": "Ligero (Preservar Calidad)",
- "balanced": "Equilibrado (Recomendado)",
- "aggressive": "Agresivo (Archivos Más Pequeños)",
- "extreme": "Extremo (Compresión Máxima)",
- "grayscale": "Convertir a Escala de Grises",
- "grayscaleHint": "Reduce el tamaño del archivo eliminando información de color",
- "customSettings": "Configuración Personalizada",
- "customSettingsHint": "Ajusta los parámetros de compresión:",
- "outputQuality": "Calidad de Salida",
- "resizeImagesTo": "Redimensionar Imágenes a",
- "onlyProcessAbove": "Solo Procesar Arriba de",
- "removeMetadata": "Eliminar metadatos",
- "subsetFonts": "Reducir fuentes (eliminar glifos no usados)",
- "removeThumbnails": "Eliminar miniaturas incrustadas",
- "compressButton": "Comprimir PDF"
+ "subtitle": "Reduce el tamaño de archivo de tu PDF."
},
"pdfEditor": {
"name": "Editor PDF",
diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json
index 86813f7b..eb5b4e47 100644
--- a/public/locales/fr/tools.json
+++ b/public/locales/fr/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Compresser un PDF",
- "subtitle": "Réduire la taille du fichier PDF.",
- "algorithmLabel": "Algorithme de compression",
- "condense": "Condensé (recommandé)",
- "photon": "Photon (pour les PDF riches en photos)",
- "condenseInfo": "Condensé utilise une compression avancée : suppression du superflu, optimisation des images, sous-ensemble des polices. Idéal pour la plupart des PDF.",
- "photonInfo": "Photon convertit les pages en images. À utiliser pour les PDF contenant beaucoup de photos ou scannés.",
- "photonWarning": "Attention : le texte ne sera plus sélectionnable et les liens ne fonctionneront plus.",
- "levelLabel": "Niveau de compression",
- "light": "Léger (préserver la qualité)",
- "balanced": "Équilibré (recommandé)",
- "aggressive": "Agressif (fichiers plus petits)",
- "extreme": "Extrême (compression maximale)",
- "grayscale": "Convertir en niveaux de gris",
- "grayscaleHint": "Réduit la taille du fichier en supprimant les informations de couleur",
- "customSettings": "Paramètres personnalisés",
- "customSettingsHint": "Affiner les paramètres de compression :",
- "outputQuality": "Qualité de sortie",
- "resizeImagesTo": "Redimensionner les images à",
- "onlyProcessAbove": "Traiter uniquement au-dessus de",
- "removeMetadata": "Supprimer les métadonnées",
- "subsetFonts": "Sous-ensemble des polices (supprimer les glyphes inutilisés)",
- "removeThumbnails": "Supprimer les vignettes intégrées",
- "compressButton": "Compresser le PDF"
+ "subtitle": "Réduire la taille du fichier PDF."
},
"pdfEditor": {
"name": "Éditeur PDF",
diff --git a/public/locales/id/tools.json b/public/locales/id/tools.json
index 0b719639..af798e0b 100644
--- a/public/locales/id/tools.json
+++ b/public/locales/id/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Kompres PDF",
- "subtitle": "Kurangi ukuran file PDF Anda.",
- "algorithmLabel": "Algoritma Kompresi",
- "condense": "Kondensasi (Direkomendasikan)",
- "photon": "Photon (Untuk PDF yang penuh Foto)",
- "condenseInfo": "Kondensasi menggunakan kompresi lanjutan: menghapus bobot mati, mengoptimalkan gambar, subset font. Terbaik untuk sebagian besar PDF.",
- "photonInfo": "Photon mengkonversi halaman ke gambar. Gunakan untuk PDF penuh foto/terpindai.",
- "photonWarning": "Peringatan: Teks akan menjadi tidak dapat dipilih dan tautan akan berhenti berfungsi.",
- "levelLabel": "Tingkat Kompresi",
- "light": "Ringan (Pertahankan Kualitas)",
- "balanced": "Seimbang (Direkomendasikan)",
- "aggressive": "Agresif (File Lebih Kecil)",
- "extreme": "Ekstrem (Kompresi Maksimum)",
- "grayscale": "Konversi ke Grayscale",
- "grayscaleHint": "Mengurangi ukuran file dengan menghapus informasi warna",
- "customSettings": "Pengaturan Kustom",
- "customSettingsHint": "Sesuaikan parameter kompresi:",
- "outputQuality": "Kualitas Output",
- "resizeImagesTo": "Ubah Ukuran Gambar Ke",
- "onlyProcessAbove": "Hanya Proses Di Atas",
- "removeMetadata": "Hapus metadata",
- "subsetFonts": "Subset font (hapus glyph yang tidak digunakan)",
- "removeThumbnails": "Hapus thumbnail tersemat",
- "compressButton": "Kompres PDF"
+ "subtitle": "Kurangi ukuran file PDF Anda."
},
"pdfEditor": {
"name": "Editor PDF",
diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json
index 29b9926b..7a4c9f08 100644
--- a/public/locales/it/tools.json
+++ b/public/locales/it/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Comprimi PDF",
- "subtitle": "Riduci le dimensioni del tuo PDF.",
- "algorithmLabel": "Algoritmo di compressione",
- "condense": "Condensa (Consigliato)",
- "photon": "Photon (Per PDF con molte foto)",
- "condenseInfo": "Condensa usa la compressione avanzata: rimuove peso inutile, ottimizza le immagini, riduce i font. Migliore per la maggior parte dei PDF.",
- "photonInfo": "Photon converte le pagine in immagini. Usalo per PDF con molte foto o scannerizzati.",
- "photonWarning": "Attenzione: il testo non sarà selezionabile e i link smetteranno di funzionare.",
- "levelLabel": "Livello di compressione",
- "light": "Leggero (Preserva qualità)",
- "balanced": "Bilanciato (Consigliato)",
- "aggressive": "Aggressivo (File più piccoli)",
- "extreme": "Estremo (Compressione massima)",
- "grayscale": "Converti in scala di grigi",
- "grayscaleHint": "Riduce le dimensioni rimuovendo le informazioni di colore",
- "customSettings": "Impostazioni personalizzate",
- "customSettingsHint": "Affina i parametri di compressione:",
- "outputQuality": "Qualità di output",
- "resizeImagesTo": "Ridimensiona le immagini a",
- "onlyProcessAbove": "Elabora solo sopra",
- "removeMetadata": "Rimuovi metadati",
- "subsetFonts": "Riduci i font (rimuovi glifi non usati)",
- "removeThumbnails": "Rimuovi miniature incorporate",
- "compressButton": "Comprimi PDF"
+ "subtitle": "Riduci le dimensioni del tuo PDF."
},
"pdfEditor": {
"name": "Editor PDF",
diff --git a/public/locales/ja/tools.json b/public/locales/ja/tools.json
index 5bf2f075..f7bcc395 100644
--- a/public/locales/ja/tools.json
+++ b/public/locales/ja/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "PDFの圧縮",
- "subtitle": "PDFのファイルサイズを小さくします。",
- "algorithmLabel": "圧縮アルゴリズム",
- "condense": "コンデンス(推奨)",
- "photon": "フォトン(写真を多く含むPDF用)",
- "condenseInfo": "コンデンスは高度な圧縮技術を採用しています: 不要なデータを削除、画像を最適化しフォントをサブセット化します。ほとんどのPDFに最適です。",
- "photonInfo": "フォトンはページを画像に変換します。写真が多いPDFやスキャンしたPDFにおすすめです。",
- "photonWarning": "警告: テキストは選択できなくなり、リンクは機能しなくなります。",
- "levelLabel": "圧縮レベル",
- "light": "ライト(品質維持)",
- "balanced": "バランス(推奨)",
- "aggressive": "アグレッシブ(ファイルをより小さく)",
- "extreme": "エクストリーム(最大圧縮)",
- "grayscale": "グレースケールに変換",
- "grayscaleHint": "カラー情報を削除してファイルサイズを削減します。",
- "customSettings": "カスタム設定",
- "customSettingsHint": "圧縮パラメータの微調整:",
- "outputQuality": "出力品質",
- "resizeImagesTo": "画像のリサイズ",
- "onlyProcessAbove": "上記の処理のみ",
- "removeMetadata": "メタデータを削除する",
- "subsetFonts": "サブセットフォント (未使用のグリフの削除)",
- "removeThumbnails": "埋め込みサムネイルを削除する",
- "compressButton": "PDFを圧縮"
+ "subtitle": "PDFのファイルサイズを小さくします。"
},
"pdfEditor": {
"name": "PDFエディター",
diff --git a/public/locales/ko/tools.json b/public/locales/ko/tools.json
index 5ce816ae..a52649ca 100644
--- a/public/locales/ko/tools.json
+++ b/public/locales/ko/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "PDF 압축",
- "subtitle": "PDF 파일 크기를 줄입니다.",
- "algorithmLabel": "압축 알고리즘",
- "condense": "Condense (권장)",
- "photon": "Photon (사진이 많은 PDF용)",
- "condenseInfo": "Condense는 불필요한 데이터 제거, 이미지 최적화, 폰트 서브셋 등 고급 압축을 적용합니다. 대부분의 PDF에 적합합니다.",
- "photonInfo": "Photon은 페이지를 이미지로 변환합니다. 사진이 많거나 스캔된 PDF에 적합합니다.",
- "photonWarning": "주의: 텍스트 선택이 불가능해지고 링크가 작동하지 않게 됩니다.",
- "levelLabel": "압축 수준",
- "light": "낮음 (품질 유지)",
- "balanced": "보통 (권장)",
- "aggressive": "높음 (파일 크기 우선)",
- "extreme": "최대 (최고 압축률)",
- "grayscale": "흑백으로 변환",
- "grayscaleHint": "색상 정보를 제거하여 파일 크기를 줄입니다",
- "customSettings": "사용자 설정",
- "customSettingsHint": "압축 세부 설정을 조정합니다:",
- "outputQuality": "출력 품질",
- "resizeImagesTo": "이미지 크기 조정",
- "onlyProcessAbove": "이 크기 이상만 처리",
- "removeMetadata": "메타데이터 제거",
- "subsetFonts": "폰트 서브셋 (미사용 글자 제거)",
- "removeThumbnails": "내장 미리보기 이미지 제거",
- "compressButton": "PDF 압축하기"
+ "subtitle": "PDF 파일 크기를 줄입니다."
},
"pdfEditor": {
"name": "PDF 편집기",
diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json
index dcd7b213..ce5adb42 100644
--- a/public/locales/nl/tools.json
+++ b/public/locales/nl/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "PDF Comprimeren",
- "subtitle": "De bestandsgrootte van een PDF verkleinen.",
- "algorithmLabel": "Compressie-algoritme",
- "condense": "Condense (Aanbevolen)",
- "photon": "Photon (Voor PDF's met veel foto's)",
- "condenseInfo": "Condense gebruikt geavanceerde compressie: verwijdert overbodige onderdelen, optimaliseert afbeeldingen en kiest alleen de benodigde letters uit fonts. Ideaal voor de meeste PDF's.",
- "photonInfo": "Photon zet pagina's om in afbeeldingen. Handig voor PDF's met veel foto's of gescande documenten.",
- "photonWarning": "Let op: De tekst kan dan niet meer geselecteerd worden en links werken niet meer.",
- "levelLabel": "Compressieniveau",
- "light": "Licht (Kwaliteit behouden)",
- "balanced": "Gebalanceerd (Aanbevolen)",
- "aggressive": "Agressief (Kleinere bestanden)",
- "extreme": "Extreem (Maximale compressie)",
- "grayscale": "Converteren naar grijswaarden",
- "grayscaleHint": "Vermindert de bestandsgrootte door kleurinformatie te verwijderen",
- "customSettings": "Aangepaste instellingen",
- "customSettingsHint": "Compressie-instellingen verfijnen:",
- "outputQuality": "Uitvoerkwaliteit",
- "resizeImagesTo": "Formaat van afbeeldingen aanpassen naar",
- "onlyProcessAbove": "Alleen verwerken boven",
- "removeMetadata": "Metagegevens wissen",
- "subsetFonts": "Subset-lettertypen (ongebruikte tekens verwijderen)",
- "removeThumbnails": "Ingesloten miniaturen verwijderen",
- "compressButton": "PDF Comprimeren"
+ "subtitle": "De bestandsgrootte van een PDF verkleinen."
},
"pdfEditor": {
"name": "PDF Bewerken",
diff --git a/public/locales/pl/tools.json b/public/locales/pl/tools.json
index e2a6a3c5..4dabba06 100644
--- a/public/locales/pl/tools.json
+++ b/public/locales/pl/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Kompresuj PDF",
- "subtitle": "Zmniejsz rozmiar pliku PDF.",
- "algorithmLabel": "Algorytm kompresji",
- "condense": "Kondensacja (Zalecane)",
- "photon": "Foton (Dla PDF z dużą ilością zdjęć)",
- "condenseInfo": "Kondensacja wykorzystuje zaawansowaną kompresję: usuwa zbędny balast, optymalizuje obrazy, zmniejsza liczbę czcionek. Najlepsze dla większości plików PDF.",
- "photonInfo": "Foton konwertuje strony na obrazy. Używaj dla zeskanowanych/fotograficznych PDF.",
- "photonWarning": "Ostrzeżenie: Tekst stanie się niemożliwy do zaznaczenia, a linki przestaną działać.",
- "levelLabel": "Poziom kompresji",
- "light": "Lekki (Zachowaj jakość)",
- "balanced": "Zbalansowany (Zalecane)",
- "aggressive": "Agresywny (Mniejsze pliki)",
- "extreme": "Ekstremalny (Maksymalna kompresja)",
- "grayscale": "Konwertuj na odcienie szarości",
- "grayscaleHint": "Zmniejsza rozmiar pliku usuwając informacje o kolorze",
- "customSettings": "Ustawienia niestandardowe",
- "customSettingsHint": "Dostrój parametry kompresji:",
- "outputQuality": "Jakość wyjściowa",
- "resizeImagesTo": "Zmień rozmiar obrazów do",
- "onlyProcessAbove": "Przetwarzaj tylko powyżej",
- "removeMetadata": "Usuń metadane",
- "subsetFonts": "Zredukuj czcionki (usuń nieużywane glify)",
- "removeThumbnails": "Usuń osadzone miniatury",
- "compressButton": "Kompresuj PDF"
+ "subtitle": "Zmniejsz rozmiar pliku PDF."
},
"pdfEditor": {
"name": "Edytor PDF",
diff --git a/public/locales/pt/tools.json b/public/locales/pt/tools.json
index b2b2a544..0482a6dd 100644
--- a/public/locales/pt/tools.json
+++ b/public/locales/pt/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Comprimir PDF",
- "subtitle": "Reduza o tamanho do arquivo do seu PDF.",
- "algorithmLabel": "Algoritmo de Compressão",
- "condense": "Condense (Recomendado)",
- "photon": "Photon (Para PDFs com Muitas Fotos)",
- "condenseInfo": "Condense usa compressão avançada: remove dados desnecessários, otimiza imagens, subconjunta fontes. Ideal para a maioria dos PDFs.",
- "photonInfo": "Photon converte páginas em imagens. Use para PDFs com muitas fotos/digitalizados.",
- "photonWarning": "Aviso: O texto ficará não selecionável e os links deixarão de funcionar.",
- "levelLabel": "Nível de Compressão",
- "light": "Leve (Preservar Qualidade)",
- "balanced": "Equilibrado (Recomendado)",
- "aggressive": "Agressivo (Arquivos Menores)",
- "extreme": "Extremo (Compressão Máxima)",
- "grayscale": "Converter para Escala de Cinza",
- "grayscaleHint": "Reduz o tamanho do arquivo removendo informações de cor",
- "customSettings": "Configurações Personalizadas",
- "customSettingsHint": "Ajuste fino dos parâmetros de compressão:",
- "outputQuality": "Qualidade de Saída",
- "resizeImagesTo": "Redimensionar Imagens Para",
- "onlyProcessAbove": "Processar Apenas Acima de",
- "removeMetadata": "Remover metadados",
- "subsetFonts": "Subconjunto de fontes (remover glifos não utilizados)",
- "removeThumbnails": "Remover miniaturas incorporadas",
- "compressButton": "Comprimir PDF"
+ "subtitle": "Reduza o tamanho do arquivo do seu PDF."
},
"pdfEditor": {
"name": "Editor de PDF",
diff --git a/public/locales/ru/tools.json b/public/locales/ru/tools.json
index ae192f11..19c38a10 100644
--- a/public/locales/ru/tools.json
+++ b/public/locales/ru/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Сжатие PDF",
- "subtitle": "Уменьшить размер PDF-файла.",
- "algorithmLabel": "Алгоритм сжатия",
- "condense": "Condense (рекомендуется)",
- "photon": "Photon (для PDF с большим числом изображений)",
- "condenseInfo": "Condense использует усовершенствованное сжатие: удаляет лишнее, оптимизирует изображения и шрифты. Лучший выбор для большинства PDF.",
- "photonInfo": "Photon преобразует страницы в изображения. Используйте для PDF с большим числом изображений или отсканированных документов.",
- "photonWarning": "Внимание! Текст будет невозможно выделить, а ссылки перестанут работать!",
- "levelLabel": "Степень сжатия",
- "light": "Низкая (сохраняет качество)",
- "balanced": "Сбалансированная (рекомендуется)",
- "aggressive": "Высокая (значительно уменьшает размер файла)",
- "extreme": "Экстремальная (максимальное сжатие)",
- "grayscale": "Преобразовать в градации серого",
- "grayscaleHint": "Уменьшает размер файла, удаляя информацию о цвете",
- "customSettings": "Расширенные настройки",
- "customSettingsHint": "Введите точные параметры сжатия:",
- "outputQuality": "Качество документа",
- "resizeImagesTo": "Разрешение изображений",
- "onlyProcessAbove": "Обрабатывать только изображения с разрешением выше",
- "removeMetadata": "Удалить метаданные",
- "subsetFonts": "Создать поднабор шрифта (удалить неиспользуемые символы)",
- "removeThumbnails": "Удалить встроенные миниатюры",
- "compressButton": "Сжать PDF"
+ "subtitle": "Уменьшить размер PDF-файла."
},
"pdfEditor": {
"name": "Редактор PDF",
diff --git a/public/locales/sk/tools.json b/public/locales/sk/tools.json
index 8b115a46..abc81e4c 100644
--- a/public/locales/sk/tools.json
+++ b/public/locales/sk/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Komprimovať PDF",
- "subtitle": "Zmenšiť veľkosť súboru vášho PDF.",
- "algorithmLabel": "Kompresný algoritmus",
- "condense": "Kondenzovať (Odporúčané)",
- "photon": "Photon (Pre PDF s množstvom fotografií)",
- "condenseInfo": "Kondenzácia využíva pokročilú kompresiu: odstraňuje zbytočné dáta, optimalizuje obrázky, vytvára podmnožiny písiem. Najlepšie pre väčšinu PDF súborov.",
- "photonInfo": "Photon konvertuje strany na obrázky. Použite pre naskenované PDF alebo PDF s množstvom fotografií.",
- "photonWarning": "Upozornenie: Text už nebude možné označiť a odkazy prestanú fungovať.",
- "levelLabel": "Úroveň kompresie",
- "light": "Mierna (Zachovať kvalitu)",
- "balanced": "Vyvážená (Odporúčané)",
- "aggressive": "Agresívna (Menšie súbory)",
- "extreme": "Extrémna (Maximálna kompresia)",
- "grayscale": "Previesť do odtieňov sivej",
- "grayscaleHint": "Znižuje veľkosť súboru odstránením informácií o farbe",
- "customSettings": "Vlastné nastavenia",
- "customSettingsHint": "Jemné doladenie parametrov kompresie:",
- "outputQuality": "Kvalita výstupu",
- "resizeImagesTo": "Zmeniť veľkosť obrázkov na",
- "onlyProcessAbove": "Spracovať iba nad",
- "removeMetadata": "Odstrániť metadáta",
- "subsetFonts": "Vytvoriť podmnožinu písiem (odstrániť nepoužívané znaky)",
- "removeThumbnails": "Odstrániť vložené miniatúry",
- "compressButton": "Komprimovať PDF"
+ "subtitle": "Zmenšiť veľkosť súboru vášho PDF."
},
"pdfEditor": {
"name": "PDF Editor",
diff --git a/public/locales/sv/tools.json b/public/locales/sv/tools.json
index d58e574c..d714ac4b 100644
--- a/public/locales/sv/tools.json
+++ b/public/locales/sv/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Komprimera PDF",
- "subtitle": "Minska filstorleken på din PDF.",
- "algorithmLabel": "Komprimeringsalgoritm",
- "condense": "Kondensera (Rekommenderas)",
- "photon": "Foton (För foto-tunga PDF:er)",
- "condenseInfo": "Kondensera använder avancerad komprimering: tar bort död vikt, optimerar bilder, undergrupperar typsnitt. Bäst för de flesta PDF:er.",
- "photonInfo": "Foton konverterar sidor till bilder. Använd för foto-tunga/scannade PDF:er.",
- "photonWarning": "Varning: Text blir icke-valbar och länkarna slutar fungera.",
- "levelLabel": "Komprimeringsnivå",
- "light": "Lätt (Bevara kvalitet)",
- "balanced": "Balanserad (Rekommenderas)",
- "aggressive": "Aggressiv (Mindre filer)",
- "extreme": "Extreme (Maximal komprimering)",
- "grayscale": "Konvertera till gråskala",
- "grayscaleHint": "Minskar filstorleken genom att ta bort färginformation",
- "customSettings": "Anpassade inställningar",
- "customSettingsHint": "Finjustera komprimeringsparametrar:",
- "outputQuality": "Utdatakvalitet",
- "resizeImagesTo": "Ändra bildstorlek till",
- "onlyProcessAbove": "Bearbeta endast över",
- "removeMetadata": "Ta bort metadata",
- "subsetFonts": "Undergruppera typsnitt (ta bort oanvända glyfer)",
- "removeThumbnails": "Ta bort inbäddade miniatyrer",
- "compressButton": "Komprimera PDF"
+ "subtitle": "Minska filstorleken på din PDF."
},
"pdfEditor": {
"name": "PDF-redigerare",
diff --git a/public/locales/tr/tools.json b/public/locales/tr/tools.json
index 1c7a80df..922fc43e 100644
--- a/public/locales/tr/tools.json
+++ b/public/locales/tr/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "PDF Sıkıştır",
- "subtitle": "PDF dosya boyutunu küçültün.",
- "algorithmLabel": "Sıkıştırma Algoritması",
- "condense": "Condense (Önerilen)",
- "photon": "Photon (Fotoğraf Ağırlıklı PDF'ler İçin)",
- "condenseInfo": "Condense gelişmiş sıkıştırma kullanır: gereksiz verileri kaldırır, görselleri optimize eder, fontları küçültür. Çoğu PDF için idealdir.",
- "photonInfo": "Photon sayfaları görsellere dönüştürür. Fotoğraf ağırlıklı/taranmış PDF'ler için kullanın.",
- "photonWarning": "Uyarı: Metin seçilemez hale gelecek ve bağlantılar çalışmayacak.",
- "levelLabel": "Sıkıştırma Seviyesi",
- "light": "Hafif (Kaliteyi Koru)",
- "balanced": "Dengeli (Önerilen)",
- "aggressive": "Agresif (Daha Küçük Dosyalar)",
- "extreme": "Aşırı (Maksimum Sıkıştırma)",
- "grayscale": "Gri Tonlamaya Dönüştür",
- "grayscaleHint": "Renk bilgisini kaldırarak dosya boyutunu küçültür",
- "customSettings": "Özel Ayarlar",
- "customSettingsHint": "Sıkıştırma parametrelerini ince ayarlayın:",
- "outputQuality": "Çıktı Kalitesi",
- "resizeImagesTo": "Görselleri Şu Boyuta Dönüştür",
- "onlyProcessAbove": "Yalnızca Şunun Üzerini İşle",
- "removeMetadata": "Meta verileri kaldır",
- "subsetFonts": "Font alt kümesi oluştur (kullanılmayan glifleri kaldır)",
- "removeThumbnails": "Gömülü küçük resimleri kaldır",
- "compressButton": "PDF'yi Sıkıştır"
+ "subtitle": "PDF dosya boyutunu küçültün."
},
"pdfEditor": {
"name": "PDF Düzenleyici",
diff --git a/public/locales/uk/tools.json b/public/locales/uk/tools.json
index 51e2f310..8354d3e0 100644
--- a/public/locales/uk/tools.json
+++ b/public/locales/uk/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Стиснення PDF",
- "subtitle": "Зменшити розмір PDF-файлу.",
- "algorithmLabel": "Алгоритм стиснення",
- "condense": "Condense (Рекомендовано)",
- "photon": "Photon (Для PDF із великою кількістю зображень)",
- "condenseInfo": "Condense використовує розширене стиснення: видаляє зайві дані, оптимізує зображення та шрифти. Найкращий варіант для більшості PDF.",
- "photonInfo": "Photon перетворює сторінки на зображення. Використовуйте для сканованих PDF або документів із великою кількістю фото.",
- "photonWarning": "Увага: текст стане недоступним для виділення, а посилання перестануть працювати.",
- "levelLabel": "Рівень стиснення",
- "light": "Легке (Збереження якості)",
- "balanced": "Збалансоване (Рекомендовано)",
- "aggressive": "Агресивне (Менший розмір)",
- "extreme": "Максимальне (Найсильніше стиснення)",
- "grayscale": "Перетворити у відтінки сірого",
- "grayscaleHint": "Зменшує розмір файлу шляхом видалення кольорової інформації",
- "customSettings": "Користувацькі налаштування",
- "customSettingsHint": "Тонке налаштування параметрів стиснення:",
- "outputQuality": "Якість виходу",
- "resizeImagesTo": "Змінити розмір зображень до",
- "onlyProcessAbove": "Обробляти лише файли більше ніж",
- "removeMetadata": "Видалити метадані",
- "subsetFonts": "Оптимізувати шрифти (видалити невикористані символи)",
- "removeThumbnails": "Видалити вбудовані мініатюри",
- "compressButton": "Стиснути PDF"
+ "subtitle": "Зменшити розмір PDF-файлу."
},
"pdfEditor": {
"name": "Редактор PDF",
diff --git a/public/locales/vi/tools.json b/public/locales/vi/tools.json
index c64f6cae..b5258d72 100644
--- a/public/locales/vi/tools.json
+++ b/public/locales/vi/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "Nén PDF",
- "subtitle": "Giảm kích thước tệp PDF của bạn.",
- "algorithmLabel": "Thuật toán nén",
- "condense": "Condense (Khuyến nghị)",
- "photon": "Photon (Dành cho PDF nhiều ảnh)",
- "condenseInfo": "Condense sử dụng nén nâng cao: loại bỏ dữ liệu thừa, tối ưu hóa hình ảnh, gọn phông chữ. Phù hợp với hầu hết PDF.",
- "photonInfo": "Photon chuyển đổi trang thành hình ảnh. Dùng cho PDF nhiều ảnh/quét.",
- "photonWarning": "Cảnh báo: Văn bản sẽ không thể chọn được và liên kết sẽ không hoạt động.",
- "levelLabel": "Mức độ nén",
- "light": "Nhẹ (Giữ chất lượng)",
- "balanced": "Cân bằng (Khuyến nghị)",
- "aggressive": "Mạnh (Tệp nhỏ hơn)",
- "extreme": "Cực đoan (Nén tối đa)",
- "grayscale": "Chuyển sang thang xám",
- "grayscaleHint": "Giảm kích thước tệp bằng cách loại bỏ thông tin màu",
- "customSettings": "Cài đặt tùy chỉnh",
- "customSettingsHint": "Tinh chỉnh các thông số nén:",
- "outputQuality": "Chất lượng đầu ra",
- "resizeImagesTo": "Thay đổi kích thước ảnh thành",
- "onlyProcessAbove": "Chỉ xử lý khi trên",
- "removeMetadata": "Xóa siêu dữ liệu",
- "subsetFonts": "Gọn phông chữ (xóa ký tự không dùng)",
- "removeThumbnails": "Xóa hình thu nhỏ nhúng",
- "compressButton": "Nén PDF"
+ "subtitle": "Giảm kích thước tệp PDF của bạn."
},
"pdfEditor": {
"name": "Trình chỉnh sửa PDF",
diff --git a/public/locales/zh-TW/tools.json b/public/locales/zh-TW/tools.json
index 3d16dd70..d0a6e195 100644
--- a/public/locales/zh-TW/tools.json
+++ b/public/locales/zh-TW/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "壓縮 PDF",
- "subtitle": "降低您的 PDF 檔案大小。",
- "algorithmLabel": "壓縮演算法",
- "condense": "Condense(建議)",
- "photon": "Photon(適用於圖片較多的 PDF)",
- "condenseInfo": "Condense 使用進階壓縮:移除冗餘資料、最佳化圖片、精簡字型。適用於大多數 PDF。",
- "photonInfo": "Photon 會將頁面轉換為圖片,適合圖片較多或由掃描產生的 PDF。",
- "photonWarning": "警告:文字將無法選取,連結將失效。",
- "levelLabel": "壓縮等級",
- "light": "輕度(保留品質)",
- "balanced": "平衡(建議)",
- "aggressive": "強力(檔案較小)",
- "extreme": "極限(最大壓縮)",
- "grayscale": "轉換為灰階",
- "grayscaleHint": "透過移除色彩資訊來縮小檔案大小",
- "customSettings": "自訂設定",
- "customSettingsHint": "微調壓縮參數:",
- "outputQuality": "輸出品質",
- "resizeImagesTo": "調整圖片至",
- "onlyProcessAbove": "僅處理高於",
- "removeMetadata": "移除中繼資料",
- "subsetFonts": "精簡字型(移除未使用的字元)",
- "removeThumbnails": "移除嵌入的縮圖",
- "compressButton": "壓縮 PDF"
+ "subtitle": "降低您的 PDF 檔案大小。"
},
"pdfEditor": {
"name": "PDF 編輯器",
diff --git a/public/locales/zh/tools.json b/public/locales/zh/tools.json
index 589b8076..dbf7a541 100644
--- a/public/locales/zh/tools.json
+++ b/public/locales/zh/tools.json
@@ -27,29 +27,7 @@
},
"compressPdf": {
"name": "压缩 PDF",
- "subtitle": "减小您的 PDF 文件大小。",
- "algorithmLabel": "压缩算法",
- "condense": "Condense(推荐)",
- "photon": "Photon(适用于图片较多的 PDF)",
- "condenseInfo": "Condense 使用高级压缩:移除冗余数据、优化图片、精简字体。适用于大多数 PDF。",
- "photonInfo": "Photon 将页面转换为图片。适用于图片较多/扫描的 PDF。",
- "photonWarning": "警告:文本将无法选择,链接将失效。",
- "levelLabel": "压缩级别",
- "light": "轻度(保持质量)",
- "balanced": "平衡(推荐)",
- "aggressive": "积极(更小文件)",
- "extreme": "极限(最大压缩)",
- "grayscale": "转换为灰度",
- "grayscaleHint": "通过移除颜色信息来减小文件大小",
- "customSettings": "自定义设置",
- "customSettingsHint": "微调压缩参数:",
- "outputQuality": "输出质量",
- "resizeImagesTo": "调整图片至",
- "onlyProcessAbove": "仅处理高于",
- "removeMetadata": "移除元数据",
- "subsetFonts": "精简字体(移除未使用的字符)",
- "removeThumbnails": "移除嵌入的缩略图",
- "compressButton": "压缩 PDF"
+ "subtitle": "减小您的 PDF 文件大小。"
},
"pdfEditor": {
"name": "PDF 编辑器",
diff --git a/src/js/logic/compress-pdf-page.ts b/src/js/logic/compress-pdf-page.ts
index 99280e8b..9b4111e9 100644
--- a/src/js/logic/compress-pdf-page.ts
+++ b/src/js/logic/compress-pdf-page.ts
@@ -1,181 +1,520 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import {
- downloadFile,
- readFileAsArrayBuffer,
- formatBytes,
- getPDFDocument,
-} from '../utils/helpers.js';
-import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
+import { downloadFile, formatBytes } from '../utils/helpers.js';
+import { deduplicateFileName } from '../utils/deduplicate-filename.js';
import { state } from '../state.js';
-import { PDFDocument } from 'pdf-lib';
import { createIcons, icons } from 'lucide';
-import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
-import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
-import * as pdfjsLib from 'pdfjs-dist';
-import type { PDFDocumentProxy } from 'pdfjs-dist';
-import '../utils/setup-pdf-worker.js';
+import {
+ HYPER_PRESETS,
+ HyperError,
+ normalizeHyperOptions,
+} from 'hyper-compress-wasm';
+import type {
+ HyperCompressOptions,
+ HyperWasmResult,
+} from 'hyper-compress-wasm';
+import type {
+ CompressFileLock,
+ CompressPasswordField,
+ HyperOptionField,
+ HyperOptionSection,
+} from '@/types';
+import {
+ applyHyperPreset,
+ compressWithHyper,
+ hasPdfHeader,
+ matchHyperPreset,
+ resolveCompressLevel,
+ targetSizeToBytes,
+ verifyHyperPassword,
+} from '../utils/compress.js';
-const CONDENSE_PRESETS = {
- light: {
- images: { quality: 90, dpiTarget: 150, dpiThreshold: 200 },
- scrub: { metadata: false, thumbnails: true },
- subsetFonts: true,
- },
- balanced: {
- images: { quality: 75, dpiTarget: 96, dpiThreshold: 150 },
- scrub: { metadata: true, thumbnails: true },
- subsetFonts: true,
- },
- aggressive: {
- images: { quality: 50, dpiTarget: 72, dpiThreshold: 100 },
- scrub: { metadata: true, thumbnails: true, xmlMetadata: true },
- subsetFonts: true,
- },
- extreme: {
- images: { quality: 30, dpiTarget: 60, dpiThreshold: 96 },
- scrub: { metadata: true, thumbnails: true, xmlMetadata: true },
- subsetFonts: true,
- },
-};
+type OptionChangeHandler = (
+ key: keyof HyperCompressOptions,
+ value: number | boolean | null
+) => void;
-const PHOTON_PRESETS = {
- light: { scale: 2.0, quality: 0.85 },
- balanced: { scale: 1.5, quality: 0.65 },
- aggressive: { scale: 1.2, quality: 0.45 },
- extreme: { scale: 1.0, quality: 0.25 },
-};
+const LOCK_MESSAGES = {
+ locked: {
+ text: 'Password protected',
+ color: 'text-gray-400',
+ field: 'border-gray-600 focus:ring-indigo-500',
+ },
+ wrong: {
+ text: 'Wrong password',
+ color: 'text-red-400',
+ field: 'border-red-500 focus:ring-red-500',
+ },
+ unlocked: {
+ text: 'Unlocked',
+ color: 'text-green-400',
+ field: 'border-green-500 focus:ring-green-500',
+ },
+} as const;
-async function performCondenseCompression(
- fileBlob: Blob,
- level: string,
- customSettings?: {
- imageQuality?: number;
- dpiTarget?: number;
- dpiThreshold?: number;
- removeMetadata?: boolean;
- subsetFonts?: boolean;
- convertToGrayscale?: boolean;
- removeThumbnails?: boolean;
+const PASSWORD_INPUT_CLASS =
+ 'w-full bg-gray-800 border text-gray-200 placeholder:text-gray-500 rounded-lg pl-9 pr-10 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent';
+
+const DPI_CHOICES = [
+ { value: 0, label: 'None' },
+ { value: 72, label: '72 DPI' },
+ { value: 96, label: '96 DPI' },
+ { value: 144, label: '144 DPI' },
+ { value: 150, label: '150 DPI' },
+ { value: 200, label: '200 DPI' },
+ { value: 300, label: '300 DPI' },
+];
+
+const HYPER_OPTION_SECTIONS: HyperOptionSection[] = [
+ {
+ title: 'Images',
+ open: true,
+ fields: [
+ {
+ key: 'imageQuality',
+ type: 'number',
+ label: 'Image quality',
+ min: 20,
+ max: 100,
+ help: 'JPEG quality for re-encoded images, 20 to 100. Lower means smaller files and more visible artifacts.',
+ },
+ {
+ key: 'maxDpi',
+ type: 'dpi',
+ label: 'Maximum resolution',
+ help: 'Images above this are resampled down. None leaves resolution alone.',
+ },
+ {
+ key: 'forceDownsample',
+ type: 'bool',
+ label: 'Always resample to that resolution',
+ help: 'Resamples even when an image is only slightly above the limit.',
+ },
+ {
+ key: 'grayscale',
+ type: 'bool',
+ label: 'Convert to grayscale',
+ help: 'Reduces file size by removing color information.',
+ },
+ {
+ key: 'reduceColor',
+ type: 'bool',
+ label: 'Reduce color complexity',
+ help: 'Only where it does not change how the image looks.',
+ },
+ {
+ key: 'clipImages',
+ type: 'bool',
+ label: 'Clip invisible parts of images',
+ },
+ {
+ key: 'removeAlternates',
+ type: 'bool',
+ label: 'Remove alternate images',
+ },
+ {
+ key: 'flattenIcc',
+ type: 'bool',
+ label: 'Simplify large color profiles',
+ },
+ {
+ key: 'preferJpx',
+ type: 'bool',
+ label: 'Prefer JPEG 2000 for photos',
+ help: 'Tries JPEG 2000 against JPEG for each image and keeps the smaller one.',
+ },
+ {
+ key: 'lossless',
+ type: 'bool',
+ label: 'Lossless (no image re-encoding)',
+ help: 'Turns off quality, resolution and every other lossy image step.',
+ },
+ ],
+ },
+ {
+ title: 'Fonts',
+ fields: [
+ {
+ key: 'subsetFonts',
+ type: 'bool',
+ label: 'Subset embedded fonts',
+ help: 'Keeps only the characters the document uses.',
+ },
+ { key: 'mergeFonts', type: 'bool', label: 'Merge duplicate fonts' },
+ {
+ key: 'removeStandardFonts',
+ type: 'bool',
+ label: 'Unembed standard fonts',
+ help: 'PDF readers supply the 14 standard PDF fonts themselves.',
+ },
+ {
+ key: 'unembedAliasedFonts',
+ type: 'bool',
+ label: 'Unembed standard-font look-alikes',
+ help: 'Arial, Times New Roman and Courier New map to the built-in equivalents. Only applied when safe.',
+ },
+ ],
+ },
+ {
+ title: 'Content',
+ fields: [
+ {
+ key: 'removeAnnots',
+ type: 'bool',
+ label: 'Flatten annotations',
+ help: 'Comments and markup become part of the page. Form fields and links keep working.',
+ },
+ {
+ key: 'flattenForms',
+ type: 'bool',
+ label: 'Flatten form fields',
+ help: 'Form fields stop being fillable.',
+ },
+ {
+ key: 'flattenLinks',
+ type: 'bool',
+ label: 'Flatten links',
+ help: 'Links stop being clickable.',
+ },
+ ],
+ },
+ {
+ title: 'Cleanup',
+ fields: [
+ {
+ key: 'removeThumbnails',
+ type: 'bool',
+ label: 'Remove page thumbnails',
+ },
+ {
+ key: 'removeAppData',
+ type: 'bool',
+ label: 'Remove application data',
+ help: 'Private data that editing apps store inside the PDF.',
+ },
+ { key: 'removeThreads', type: 'bool', label: 'Remove article threads' },
+ {
+ key: 'removeSpiderInfo',
+ type: 'bool',
+ label: 'Remove web capture information',
+ },
+ {
+ key: 'removeStructTree',
+ type: 'bool',
+ label: 'Remove document structure tree',
+ help: 'Drops tagging, which screen readers rely on.',
+ },
+ {
+ key: 'removeOutputIntents',
+ type: 'bool',
+ label: 'Remove output intents',
+ },
+ ],
+ },
+ {
+ title: 'Rasterize',
+ fields: [
+ {
+ key: 'rasterizePages',
+ type: 'bool',
+ label: 'Rasterize pages to images',
+ help: 'For image-only PDFs such as scans. Skipped when the document has selectable text, unless Override content protection is on.',
+ },
+ {
+ key: 'rasterizeDpi',
+ type: 'number',
+ label: 'Rasterize resolution (DPI)',
+ min: 36,
+ max: 600,
+ },
+ {
+ key: 'rasterizeQuality',
+ type: 'number',
+ label: 'Rasterize quality',
+ min: 1,
+ max: 100,
+ },
+ ],
+ },
+ {
+ title: 'Output',
+ fields: [
+ {
+ key: 'preserveConformance',
+ type: 'bool',
+ label: 'Preserve PDF/A',
+ help: 'Keeps a PDF/A file conformant. Brotli and rasterizing are skipped while this is on.',
+ },
+ {
+ key: 'brotli',
+ type: 'bool',
+ label: 'Brotli compression (PDF 2.0)',
+ help: 'Smaller files, but only readers with Brotli support can open them, such as Firefox, MuPDF 1.26+ and Ghostscript 10.06+.',
+ },
+ ],
+ },
+];
+
+function buildOptionField(
+ field: HyperOptionField,
+ onChange: OptionChangeHandler
+): { element: HTMLElement; sync: (options: HyperCompressOptions) => void } {
+ const id = `hyper-opt-${field.key}`;
+ const wrapper = document.createElement('div');
+ let help: HTMLParagraphElement | null = null;
+ if (field.help) {
+ help = document.createElement('p');
+ help.className = 'text-xs text-gray-500 mt-0.5';
+ help.textContent = field.help;
}
-) {
- // Load PyMuPDF dynamically from user-provided URL
- const pymupdf = await loadPyMuPDF();
- const preset =
- CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] ||
- CONDENSE_PRESETS.balanced;
+ if (field.type === 'bool') {
+ const label = document.createElement('label');
+ label.className = 'flex items-start gap-2 cursor-pointer';
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.id = id;
+ input.className = 'w-4 h-4 mt-0.5 rounded flex-shrink-0';
+ input.addEventListener('change', () => onChange(field.key, input.checked));
+ const text = document.createElement('span');
+ const title = document.createElement('span');
+ title.className = 'block text-sm text-gray-300';
+ title.textContent = field.label;
+ text.append(title);
+ if (help) text.append(help);
+ label.append(input, text);
+ wrapper.append(label);
+ return {
+ element: wrapper,
+ sync: (options) => {
+ input.checked = Boolean(options[field.key]);
+ },
+ };
+ }
- const dpiTarget = customSettings?.dpiTarget ?? preset.images.dpiTarget;
- const userThreshold =
- customSettings?.dpiThreshold ?? preset.images.dpiThreshold;
- const dpiThreshold = Math.max(userThreshold, dpiTarget + 10);
+ const label = document.createElement('label');
+ label.htmlFor = id;
+ label.className = 'block mb-1 text-xs font-medium text-gray-400';
+ label.textContent = field.label;
+ wrapper.append(label);
+ const controlClass =
+ 'w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm';
- const options = {
- images: {
- enabled: true,
- quality: customSettings?.imageQuality ?? preset.images.quality,
- dpiTarget,
- dpiThreshold,
- convertToGray: customSettings?.convertToGrayscale ?? false,
- },
- scrub: {
- metadata: customSettings?.removeMetadata ?? preset.scrub.metadata,
- thumbnails: customSettings?.removeThumbnails ?? preset.scrub.thumbnails,
- xmlMetadata:
- 'xmlMetadata' in preset.scrub
- ? (preset.scrub as { xmlMetadata: boolean }).xmlMetadata
- : false,
- },
- subsetFonts: customSettings?.subsetFonts ?? preset.subsetFonts,
- save: {
- garbage: 4 as const,
- deflate: true,
- clean: true,
- useObjstms: true,
+ if (field.type === 'dpi') {
+ const select = document.createElement('select');
+ select.id = id;
+ select.className = controlClass;
+ for (const choice of DPI_CHOICES) {
+ const option = document.createElement('option');
+ option.value = String(choice.value);
+ option.textContent = choice.label;
+ select.append(option);
+ }
+ select.addEventListener('change', () =>
+ onChange(field.key, Number(select.value))
+ );
+ wrapper.append(select);
+ if (help) wrapper.append(help);
+ return {
+ element: wrapper,
+ sync: (options) => {
+ select.value = String(options[field.key]);
+ },
+ };
+ }
+
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.id = id;
+ input.min = String(field.min);
+ input.max = String(field.max);
+ input.step = '1';
+ input.className = controlClass;
+ input.addEventListener('change', () => {
+ const value = Number(input.value);
+ if (input.value.trim() === '' || !Number.isFinite(value)) {
+ onChange(field.key, null);
+ return;
+ }
+ onChange(
+ field.key,
+ Math.min(field.max, Math.max(field.min, Math.round(value)))
+ );
+ });
+ wrapper.append(input);
+ if (help) wrapper.append(help);
+ return {
+ element: wrapper,
+ sync: (options) => {
+ input.value = String(options[field.key]);
},
};
-
- try {
- const result = await pymupdf.compressPdf(fileBlob, options);
- return result;
- } catch (error: unknown) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (
- errorMessage.includes('PatternType') ||
- errorMessage.includes('pattern')
- ) {
- console.warn(
- '[CompressPDF] Pattern error detected, retrying without image rewriting:',
- errorMessage
- );
-
- const fallbackOptions = {
- ...options,
- images: {
- ...options.images,
- enabled: false,
- },
- };
-
- const result = await pymupdf.compressPdf(fileBlob, fallbackOptions);
- return { ...result, usedFallback: true };
- }
-
- throw new Error(`PDF compression failed: ${errorMessage}`, {
- cause: error,
- });
- }
}
-async function performPhotonCompression(
- arrayBuffer: ArrayBuffer,
- level: string,
- file?: File
-) {
- let pdfJsDoc: PDFDocumentProxy;
- if (file) {
- hideLoader();
- const result = await loadPdfWithPasswordPrompt(file);
- if (!result) return null;
- showLoader('Running Photon compression...');
- pdfJsDoc = result.pdf;
- } else {
- pdfJsDoc = await getPDFDocument({ data: arrayBuffer }).promise;
+function renderHyperOptions(
+ root: HTMLElement,
+ onChange: OptionChangeHandler
+): (options: HyperCompressOptions) => void {
+ const syncs: ((options: HyperCompressOptions) => void)[] = [];
+ for (const section of HYPER_OPTION_SECTIONS) {
+ const details = document.createElement('details');
+ details.className = 'group rounded-lg border border-gray-700';
+ details.open = Boolean(section.open);
+ const summary = document.createElement('summary');
+ summary.className =
+ 'flex items-center justify-between cursor-pointer select-none list-none px-3 py-2 text-sm font-medium text-gray-200 [&::-webkit-details-marker]:hidden';
+ summary.textContent = section.title;
+ const chevron = document.createElement('i');
+ chevron.setAttribute('data-lucide', 'chevron-down');
+ chevron.className =
+ 'w-4 h-4 text-gray-400 transition-transform group-open:rotate-180';
+ summary.append(chevron);
+ const body = document.createElement('div');
+ body.className = 'space-y-3 px-3 pb-3';
+ for (const field of section.fields) {
+ const { element, sync } = buildOptionField(field, onChange);
+ body.append(element);
+ syncs.push(sync);
+ }
+ details.append(summary, body);
+ root.append(details);
}
- const newPdfDoc = await PDFDocument.create();
- const settings =
- PHOTON_PRESETS[level as keyof typeof PHOTON_PRESETS] ||
- PHOTON_PRESETS.balanced;
+ return (options) => {
+ for (const sync of syncs) sync(options);
+ };
+}
- for (let i = 1; i <= pdfJsDoc.numPages; i++) {
- const page = await pdfJsDoc.getPage(i);
- const viewport = page.getViewport({ scale: settings.scale });
- const canvas = document.createElement('canvas');
- const context = canvas.getContext('2d');
- canvas.height = viewport.height;
- canvas.width = viewport.width;
-
- await page.render({ canvasContext: context, viewport, canvas: canvas })
- .promise;
-
- const jpegBlob = await new Promise((resolve) =>
- canvas.toBlob(
- (blob) => resolve(blob as Blob),
- 'image/jpeg',
- settings.quality
- )
+function describeCompressResult(
+ result: HyperWasmResult,
+ options: HyperCompressOptions,
+ targetBytes: number | undefined
+): { title: string; message: string; type: string } {
+ const saved = result.originalSize - result.compressedSize;
+ const parts: string[] = [];
+ if (saved > 0) {
+ const percent = ((saved / result.originalSize) * 100).toFixed(1);
+ parts.push(
+ `File size reduced from ${formatBytes(result.originalSize)} to ${formatBytes(result.compressedSize)} (Saved ${percent}%).`
+ );
+ } else {
+ parts.push(
+ `Could not reduce file size further. Original: ${formatBytes(result.originalSize)}, New: ${formatBytes(result.compressedSize)}.`
);
- const jpegBytes = await jpegBlob.arrayBuffer();
- const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
- const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
- newPage.drawImage(jpegImage, {
- x: 0,
- y: 0,
- width: viewport.width,
- height: viewport.height,
- });
}
- return await newPdfDoc.save();
+
+ const preservingPdfa = options.preserveConformance && result.pdfa !== null;
+ if (targetBytes !== undefined) {
+ if (result.metTarget) {
+ parts.push(`It fits the ${formatBytes(targetBytes)} target.`);
+ } else if (preservingPdfa) {
+ parts.push(
+ `It is still above the ${formatBytes(targetBytes)} target because Preserve PDF/A limits how far it can be compressed.`
+ );
+ } else {
+ parts.push(
+ `It is still above the ${formatBytes(targetBytes)} target. This is the smallest these settings allow; for image-only scans, turn on Rasterize pages under Custom Settings.`
+ );
+ }
+ }
+
+ if (result.signaturesRemoved) {
+ parts.push(
+ 'Its digital signatures were removed so it could be compressed. Sign it again if needed.'
+ );
+ }
+
+ if (result.pdfaOutcome === 'claim-withdrawn') {
+ parts.push(
+ 'It no longer claims PDF/A conformance. Turn on Preserve PDF/A under Custom Settings to keep it.'
+ );
+ } else if (preservingPdfa && (options.brotli || options.rasterizePages)) {
+ parts.push('Brotli and rasterizing were skipped to keep it PDF/A.');
+ }
+
+ if (saved <= 0) {
+ return {
+ title: 'Compression Finished',
+ message: parts.join(' '),
+ type: 'warning',
+ };
+ }
+ if (result.metTarget === false) {
+ return {
+ title: 'Target Not Reached',
+ message: parts.join(' '),
+ type: 'warning',
+ };
+ }
+ return {
+ title: 'Compression Complete',
+ message: parts.join(' '),
+ type: 'success',
+ };
+}
+
+function describeCompressionError(err: unknown, hadPassword: boolean): string {
+ if (err instanceof HyperError && err.code === 'decrypt_failed') {
+ return hadPassword ? 'wrong password' : 'password protected';
+ }
+ return err instanceof Error ? err.message : String(err);
+}
+
+function createPasswordField(
+ label: string,
+ value: string,
+ onInput: (value: string) => void
+): CompressPasswordField {
+ const root = document.createElement('div');
+ root.className = 'relative w-full sm:max-w-sm';
+
+ const lockIcon = createIcon(
+ 'lock',
+ 'pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400'
+ );
+ const unlockIcon = createIcon(
+ 'lock-open',
+ 'pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-green-400 hidden'
+ );
+
+ const input = document.createElement('input');
+ input.type = 'password';
+ input.placeholder = 'Enter password';
+ input.autocomplete = 'off';
+ input.value = value;
+ input.setAttribute('aria-label', label);
+ input.className = `${PASSWORD_INPUT_CLASS} ${LOCK_MESSAGES.locked.field}`;
+ input.addEventListener('input', () => onInput(input.value));
+
+ const showIcon = createIcon('eye', '');
+ const hideIcon = createIcon('eye-slash', 'hidden');
+ const toggle = document.createElement('button');
+ toggle.type = 'button';
+ toggle.className =
+ 'absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-200';
+ toggle.setAttribute('aria-label', 'Show password');
+ toggle.append(showIcon, hideIcon);
+ toggle.addEventListener('click', () => {
+ const reveal = input.type === 'password';
+ input.type = reveal ? 'text' : 'password';
+ toggle.setAttribute(
+ 'aria-label',
+ reveal ? 'Hide password' : 'Show password'
+ );
+ showIcon.classList.toggle('hidden', reveal);
+ hideIcon.classList.toggle('hidden', !reveal);
+ });
+
+ root.append(lockIcon, unlockIcon, input, toggle);
+
+ const status = document.createElement('p');
+ status.className = 'text-xs mt-1 text-gray-400';
+ status.setAttribute('aria-live', 'polite');
+ return { root, input, status, lockIcon, unlockIcon };
+}
+
+function createIcon(name: string, className: string): HTMLElement {
+ const icon = document.createElement('i');
+ icon.className = `ph ph-${name} text-base ${className}`;
+ return icon;
}
document.addEventListener('DOMContentLoaded', () => {
@@ -186,11 +525,18 @@ document.addEventListener('DOMContentLoaded', () => {
const clearFilesBtn = document.getElementById('clear-files-btn');
const processBtn = document.getElementById('process-btn');
const backBtn = document.getElementById('back-to-tools');
- const algorithmSelect = document.getElementById(
- 'compression-algorithm'
+ const levelSelect = document.getElementById(
+ 'compression-level'
) as HTMLSelectElement;
- const condenseInfo = document.getElementById('condense-info');
- const photonInfo = document.getElementById('photon-info');
+ const targetSizeInput = document.getElementById(
+ 'target-size'
+ ) as HTMLInputElement;
+ const targetSizeUnit = document.getElementById(
+ 'target-size-unit'
+ ) as HTMLSelectElement;
+ const overrideInput = document.getElementById(
+ 'override-protection'
+ ) as HTMLInputElement;
const toggleCustomSettings = document.getElementById(
'toggle-custom-settings'
);
@@ -198,8 +544,13 @@ document.addEventListener('DOMContentLoaded', () => {
const customSettingsChevron = document.getElementById(
'custom-settings-chevron'
);
+ const hyperOptionsRoot = document.getElementById('hyper-options');
- let useCustomSettings = false;
+ const defaultTargetUnit =
+ targetSizeUnit?.dataset.defaultUnit === 'kb' ? 'kb' : 'mb';
+ let options: HyperCompressOptions = normalizeHyperOptions(
+ HYPER_PRESETS.medium
+ );
if (backBtn) {
backBtn.addEventListener('click', () => {
@@ -207,33 +558,164 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
- // Toggle algorithm info
- if (algorithmSelect && condenseInfo && photonInfo) {
- algorithmSelect.addEventListener('change', () => {
- if (algorithmSelect.value === 'condense') {
- condenseInfo.classList.remove('hidden');
- photonInfo.classList.add('hidden');
- } else {
- condenseInfo.classList.add('hidden');
- photonInfo.classList.remove('hidden');
- }
- });
- }
+ const setCustomSettingsOpen = (open: boolean) => {
+ if (!customSettingsPanel || !customSettingsChevron) return;
+ customSettingsPanel.classList.toggle('hidden', !open);
+ customSettingsChevron.style.transform = open
+ ? 'rotate(180deg)'
+ : 'rotate(0deg)';
+ };
+
+ const syncLevel = () => {
+ if (levelSelect) levelSelect.value = matchHyperPreset(options) ?? 'custom';
+ };
+
+ const syncOptionControls: (options: HyperCompressOptions) => void =
+ hyperOptionsRoot
+ ? renderHyperOptions(hyperOptionsRoot, (key, value) => {
+ if (value !== null) {
+ options = normalizeHyperOptions({ ...options, [key]: value });
+ }
+ syncOptionControls(options);
+ syncLevel();
+ })
+ : () => {};
+
+ levelSelect?.addEventListener('change', () => {
+ if (levelSelect.value === 'custom') {
+ setCustomSettingsOpen(true);
+ return;
+ }
+ options = applyHyperPreset(
+ resolveCompressLevel(levelSelect.value),
+ options
+ );
+ syncOptionControls(options);
+ });
+
+ overrideInput?.addEventListener('change', () => {
+ options = {
+ ...options,
+ rasterizeText: overrideInput.checked,
+ removeSignatures: overrideInput.checked,
+ };
+ });
// Toggle custom settings panel
- if (toggleCustomSettings && customSettingsPanel && customSettingsChevron) {
- toggleCustomSettings.addEventListener('click', () => {
- customSettingsPanel.classList.toggle('hidden');
- customSettingsChevron.style.transform =
- customSettingsPanel.classList.contains('hidden')
- ? 'rotate(0deg)'
- : 'rotate(180deg)';
- // Mark that user wants to use custom settings
- if (!customSettingsPanel.classList.contains('hidden')) {
- useCustomSettings = true;
+ toggleCustomSettings?.addEventListener('click', () => {
+ setCustomSettingsOpen(
+ customSettingsPanel?.classList.contains('hidden') ?? false
+ );
+ });
+
+ const applyDefaults = () => {
+ options = normalizeHyperOptions(HYPER_PRESETS.medium);
+ if (overrideInput) overrideInput.checked = false;
+ if (targetSizeInput) targetSizeInput.value = targetSizeInput.defaultValue;
+ if (targetSizeUnit) targetSizeUnit.value = defaultTargetUnit;
+ setCustomSettingsOpen(false);
+ syncOptionControls(options);
+ syncLevel();
+ };
+
+ const fileLocks = new Map();
+ const lockViews = new Map();
+ let sharedView: CompressPasswordField | null = null;
+ let sharedPassword = '';
+ let sharedAttempt = 0;
+ let sharedMissed = false;
+
+ const isProtected = (
+ lock: CompressFileLock | undefined
+ ): lock is CompressFileLock =>
+ lock !== undefined && lock.status !== 'checking' && lock.status !== 'open';
+
+ const protectedFiles = () =>
+ state.files.filter((file) => isProtected(fileLocks.get(file)));
+
+ const tryPassword = async (
+ file: File,
+ password: string
+ ): Promise => {
+ try {
+ return await verifyHyperPassword(await file.arrayBuffer(), password);
+ } catch (err) {
+ console.error('[CompressPDF] Password check failed:', err);
+ return null;
+ }
+ };
+
+ const paintShared = () => {
+ if (!sharedView) return;
+ const files = protectedFiles();
+ const unlocked = files.filter(
+ (file) => fileLocks.get(file)?.status === 'unlocked'
+ ).length;
+ if (sharedMissed && unlocked < files.length) {
+ sharedView.status.textContent =
+ 'That password did not open any of the remaining files.';
+ sharedView.status.className = 'text-xs mt-1 text-red-400';
+ return;
+ }
+ sharedView.status.textContent = `Unlocked ${unlocked} of ${files.length} files.`;
+ sharedView.status.className = `text-xs mt-1 ${unlocked === files.length ? 'text-green-400' : 'text-gray-400'}`;
+ };
+
+ const paintLock = (file: File) => {
+ const lock = fileLocks.get(file);
+ const view = lockViews.get(file);
+ if (lock && view && lock.status !== 'checking' && lock.status !== 'open') {
+ const { text, color, field } = LOCK_MESSAGES[lock.status];
+ if (view.input.value !== lock.password) view.input.value = lock.password;
+ view.status.textContent = text;
+ view.status.className = `truncate ${color}`;
+ view.input.className = `${PASSWORD_INPUT_CLASS} ${field}`;
+ view.lockIcon.classList.toggle('hidden', lock.status === 'unlocked');
+ view.unlockIcon.classList.toggle('hidden', lock.status !== 'unlocked');
+ }
+ paintShared();
+ };
+
+ const checkCardPassword = (file: File, lock: CompressFileLock) => {
+ const attempt = ++lock.attempt;
+ lock.status = 'locked';
+ paintLock(file);
+ if (!lock.password) return;
+ window.setTimeout(async () => {
+ if (lock.attempt !== attempt) return;
+ const valid = await tryPassword(file, lock.password);
+ if (lock.attempt !== attempt || valid === null) return;
+ lock.status = valid ? 'unlocked' : 'wrong';
+ paintLock(file);
+ }, 400);
+ };
+
+ const applySharedPassword = (password: string) => {
+ const attempt = ++sharedAttempt;
+ sharedPassword = password;
+ sharedMissed = false;
+ paintShared();
+ if (!password) return;
+ window.setTimeout(async () => {
+ if (attempt !== sharedAttempt) return;
+ let matched = 0;
+ for (const file of protectedFiles()) {
+ const lock = fileLocks.get(file);
+ if (!lock || lock.status === 'unlocked') continue;
+ const before = lock.attempt;
+ const valid = await tryPassword(file, password);
+ if (attempt !== sharedAttempt) return;
+ if (!valid || lock.attempt !== before) continue;
+ lock.attempt++;
+ lock.password = password;
+ lock.status = 'unlocked';
+ matched++;
+ paintLock(file);
}
- });
- }
+ sharedMissed = matched === 0;
+ paintShared();
+ }, 400);
+ };
const updateUI = async () => {
if (!compressOptions) return;
@@ -241,16 +723,41 @@ document.addEventListener('DOMContentLoaded', () => {
if (state.files.length > 0) {
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) {
- fileDisplayArea.innerHTML = '';
+ const focusedFile = [...lockViews].find(
+ ([, view]) => view.input === document.activeElement
+ )?.[0];
+ const sharedFocused = sharedView?.input === document.activeElement;
+ lockViews.clear();
+ sharedView = null;
+ fileDisplayArea.replaceChildren();
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
+ if (protectedFiles().length > 1) {
+ const sharedDiv = document.createElement('div');
+ sharedDiv.className = 'bg-gray-700 p-3 rounded-lg text-sm';
+ const sharedLabel = document.createElement('div');
+ sharedLabel.className = 'font-medium text-gray-200 text-sm';
+ sharedLabel.textContent = 'Password for all locked files';
+ sharedView = createPasswordField(
+ 'Password for all locked files',
+ sharedPassword,
+ applySharedPassword
+ );
+ sharedView.root.classList.add('mt-2');
+ sharedDiv.append(sharedLabel, sharedView.root, sharedView.status);
+ fileDisplayArea.appendChild(sharedDiv);
+ }
+
+ for (const file of state.files) {
+ const lock = fileLocks.get(file);
+ const locked = isProtected(lock);
const fileDiv = document.createElement('div');
- fileDiv.className =
- 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ fileDiv.className = 'bg-gray-700 p-3 rounded-lg text-sm';
+
+ const headerRow = document.createElement('div');
+ headerRow.className = 'flex items-center justify-between';
const infoContainer = document.createElement('div');
- infoContainer.className = 'flex flex-col overflow-hidden';
+ infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className =
@@ -262,292 +769,275 @@ document.addEventListener('DOMContentLoaded', () => {
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
+ headerRow.append(infoContainer);
+ fileDiv.append(headerRow);
+
+ if (locked) {
+ const view = createPasswordField(
+ `Password for ${file.name}`,
+ lock.password,
+ (value) => {
+ lock.password = value;
+ checkCardPassword(file, lock);
+ }
+ );
+ lockViews.set(file, view);
+ view.root.classList.add('mt-3');
+ const sizeSpan = document.createElement('span');
+ sizeSpan.className = 'flex-shrink-0 whitespace-nowrap';
+ sizeSpan.textContent = `${formatBytes(file.size)} ·`;
+ metaSpan.className = 'flex gap-1 min-w-0 text-xs text-gray-400';
+ metaSpan.replaceChildren(sizeSpan, view.status);
+ fileDiv.append(view.root);
+ }
const removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = '';
+ removeBtn.setAttribute('aria-label', `Remove ${file.name}`);
+ removeBtn.append(createIcon('trash', ''));
removeBtn.onclick = () => {
- state.files = state.files.filter((_, i) => i !== index);
+ state.files = state.files.filter((f) => f !== file);
+ fileLocks.delete(file);
updateUI();
};
- fileDiv.append(infoContainer, removeBtn);
+ headerRow.append(removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
- createIcons({ icons });
+ for (const file of lockViews.keys()) paintLock(file);
+ paintShared();
+ if (focusedFile) lockViews.get(focusedFile)?.input.focus();
+ if (sharedFocused) sharedView?.input.focus();
}
compressOptions.classList.remove('hidden');
} else {
compressOptions.classList.add('hidden');
// Clear file display area
const fileDisplayArea = document.getElementById('file-display-area');
- if (fileDisplayArea) fileDisplayArea.innerHTML = '';
+ if (fileDisplayArea) fileDisplayArea.replaceChildren();
}
};
const resetState = () => {
state.files = [];
state.pdfDoc = null;
-
- const compressionLevel = document.getElementById(
- 'compression-level'
- ) as HTMLSelectElement;
- if (compressionLevel) compressionLevel.value = 'balanced';
-
- if (algorithmSelect) algorithmSelect.value = 'condense';
-
- useCustomSettings = false;
- if (customSettingsPanel) customSettingsPanel.classList.add('hidden');
- if (customSettingsChevron)
- customSettingsChevron.style.transform = 'rotate(0deg)';
-
- const imageQuality = document.getElementById(
- 'image-quality'
- ) as HTMLInputElement;
- const dpiTarget = document.getElementById('dpi-target') as HTMLInputElement;
- const dpiThreshold = document.getElementById(
- 'dpi-threshold'
- ) as HTMLInputElement;
- const removeMetadata = document.getElementById(
- 'remove-metadata'
- ) as HTMLInputElement;
- const subsetFonts = document.getElementById(
- 'subset-fonts'
- ) as HTMLInputElement;
- const convertToGrayscale = document.getElementById(
- 'convert-to-grayscale'
- ) as HTMLInputElement;
- const removeThumbnails = document.getElementById(
- 'remove-thumbnails'
- ) as HTMLInputElement;
-
- if (imageQuality) imageQuality.value = '75';
- if (dpiTarget) dpiTarget.value = '96';
- if (dpiThreshold) dpiThreshold.value = '150';
- if (removeMetadata) removeMetadata.checked = true;
- if (subsetFonts) subsetFonts.checked = true;
- if (convertToGrayscale) convertToGrayscale.checked = false;
- if (removeThumbnails) removeThumbnails.checked = true;
-
- if (condenseInfo) condenseInfo.classList.remove('hidden');
- if (photonInfo) photonInfo.classList.add('hidden');
-
+ fileLocks.clear();
+ sharedPassword = '';
+ sharedAttempt++;
+ sharedMissed = false;
+ applyDefaults();
updateUI();
};
- const compress = async () => {
- const level = (
- document.getElementById('compression-level') as HTMLSelectElement
- ).value;
- const algorithm = (
- document.getElementById('compression-algorithm') as HTMLSelectElement
- ).value;
- const convertToGrayscale =
- (document.getElementById('convert-to-grayscale') as HTMLInputElement)
- ?.checked ?? false;
+ const compressSingle = async (
+ file: File,
+ runOptions: HyperCompressOptions,
+ targetBytes: number | undefined
+ ) => {
+ const lock = fileLocks.get(file);
+ const password = isProtected(lock) ? lock.password : '';
+ showLoader(
+ targetBytes === undefined
+ ? 'Compressing...'
+ : `Compressing to ${formatBytes(targetBytes)}...`
+ );
+ const result = await compressWithHyper(await file.arrayBuffer(), {
+ options: runOptions,
+ targetSizeBytes: targetBytes,
+ password,
+ });
+ hideLoader();
- let customSettings:
- | {
- imageQuality?: number;
- dpiTarget?: number;
- dpiThreshold?: number;
- removeMetadata?: boolean;
- subsetFonts?: boolean;
- convertToGrayscale?: boolean;
- removeThumbnails?: boolean;
- }
- | undefined;
-
- if (useCustomSettings) {
- const imageQuality =
- parseInt(
- (document.getElementById('image-quality') as HTMLInputElement)?.value
- ) || 75;
- const dpiTarget =
- parseInt(
- (document.getElementById('dpi-target') as HTMLInputElement)?.value
- ) || 96;
- const dpiThreshold =
- parseInt(
- (document.getElementById('dpi-threshold') as HTMLInputElement)?.value
- ) || 150;
- const removeMetadata =
- (document.getElementById('remove-metadata') as HTMLInputElement)
- ?.checked ?? true;
- const subsetFonts =
- (document.getElementById('subset-fonts') as HTMLInputElement)
- ?.checked ?? true;
- const removeThumbnails =
- (document.getElementById('remove-thumbnails') as HTMLInputElement)
- ?.checked ?? true;
-
- customSettings = {
- imageQuality,
- dpiTarget,
- dpiThreshold,
- removeMetadata,
- subsetFonts,
- convertToGrayscale,
- removeThumbnails,
- };
- } else {
- customSettings = convertToGrayscale ? { convertToGrayscale } : undefined;
+ if (result.signed) {
+ showAlert(
+ 'Signed PDF Not Changed',
+ `${file.name} is digitally signed, so it was left as it is. Compressing it would break the signature. To compress it anyway, turn on Override content protection under Custom Settings; its signatures will be removed.`,
+ 'warning',
+ () => resetState()
+ );
+ return;
}
+ downloadFile(
+ new Blob([new Uint8Array(result.data)], { type: 'application/pdf' }),
+ file.name
+ );
+ const { title, message, type } = describeCompressResult(
+ result,
+ runOptions,
+ targetBytes
+ );
+ showAlert(
+ title,
+ password
+ ? `${message} It was password protected, so the compressed copy has no password. Use Protect PDF to add one again.`
+ : message,
+ type,
+ () => resetState()
+ );
+ };
+
+ const compressBatch = async (
+ files: File[],
+ runOptions: HyperCompressOptions,
+ targetBytes: number | undefined
+ ) => {
+ showLoader('Compressing multiple PDFs...');
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
+ let totalOriginalSize = 0;
+ let totalCompressedSize = 0;
+ let compressedCount = 0;
+ let signedCount = 0;
+ let signaturesRemovedCount = 0;
+ let missedTargetCount = 0;
+ let withdrawnPdfaCount = 0;
+ let unlockedCount = 0;
+ const failures: string[] = [];
+ const zipNames = new Set();
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ showLoader(`Compressing ${i + 1}/${files.length}: ${file.name}...`);
+ const lock = fileLocks.get(file);
+ const password = isProtected(lock) ? lock.password : '';
+ try {
+ const result = await compressWithHyper(await file.arrayBuffer(), {
+ options: runOptions,
+ targetSizeBytes: targetBytes,
+ password,
+ });
+ if (password) unlockedCount++;
+ if (result.signed) signedCount++;
+ if (result.signaturesRemoved) signaturesRemovedCount++;
+ if (result.metTarget === false) missedTargetCount++;
+ if (result.pdfaOutcome === 'claim-withdrawn') withdrawnPdfaCount++;
+ zip.file(deduplicateFileName(file.name, zipNames), result.data);
+ totalOriginalSize += file.size;
+ totalCompressedSize += result.data.length;
+ compressedCount++;
+ } catch (err) {
+ console.error('[CompressPDF] Error:', err);
+ failures.push(
+ `${file.name} (${describeCompressionError(err, password !== '')})`
+ );
+ }
+ }
+
+ if (compressedCount === 0) {
+ hideLoader();
+ showAlert(
+ 'Error',
+ `None of the files could be compressed: ${failures.join(', ')}.`
+ );
+ return;
+ }
+
+ showLoader('Creating ZIP...');
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, 'compressed-pdfs.zip');
+ hideLoader();
+
+ const totalSavings = totalOriginalSize - totalCompressedSize;
+ const parts = [
+ totalSavings > 0
+ ? `Compressed ${compressedCount} PDF(s). Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${((totalSavings / totalOriginalSize) * 100).toFixed(1)}%).`
+ : `Compressed ${compressedCount} PDF(s). Total size: ${formatBytes(totalCompressedSize)}.`,
+ ];
+ if (signedCount > 0) {
+ parts.push(
+ `${signedCount} digitally signed PDF(s) were left as they are to keep their signatures valid. Turn on Override content protection under Custom Settings to compress them anyway.`
+ );
+ }
+ if (signaturesRemovedCount > 0) {
+ parts.push(
+ `${signaturesRemovedCount} had their digital signatures removed so they could be compressed.`
+ );
+ }
+ if (unlockedCount > 0) {
+ parts.push(
+ `${unlockedCount} password-protected PDF(s) were unlocked, so their compressed copies have no password. Use Protect PDF to add one again.`
+ );
+ }
+ if (missedTargetCount > 0 && targetBytes !== undefined) {
+ parts.push(
+ `${missedTargetCount} could not get under the ${formatBytes(targetBytes)} target.`
+ );
+ }
+ if (withdrawnPdfaCount > 0) {
+ parts.push(
+ `${withdrawnPdfaCount} no longer claim PDF/A conformance. Turn on Preserve PDF/A under Custom Settings to keep it.`
+ );
+ }
+ if (failures.length > 0) {
+ parts.push(`Skipped: ${failures.join(', ')}.`);
+ }
+
+ const hasProblems = failures.length > 0 || missedTargetCount > 0;
+ showAlert(
+ hasProblems ? 'Compression Finished' : 'Compression Complete',
+ parts.join(' '),
+ hasProblems ? 'warning' : totalSavings > 0 ? 'success' : 'info',
+ () => resetState()
+ );
+ };
+
+ const compress = async () => {
+ if (state.files.length === 0) {
+ showAlert('No Files', 'Please select at least one PDF file.');
+ return;
+ }
+
+ const needPassword = state.files.filter((file) => {
+ const lock = fileLocks.get(file);
+ return isProtected(lock) && (lock.status === 'wrong' || !lock.password);
+ });
+ if (needPassword.length > 0) {
+ showAlert(
+ 'Password Needed',
+ needPassword.length === 1
+ ? `${needPassword[0].name} is password protected. Enter its password on the file card, then compress again.`
+ : `These files are password protected: ${needPassword.map((file) => file.name).join(', ')}. Enter their passwords on the file cards, or remove them, then compress again.`,
+ 'warning',
+ () => lockViews.get(needPassword[0])?.input.focus()
+ );
+ return;
+ }
+
+ const runOptions = { ...options };
+ const targetBytes =
+ targetSizeInput && targetSizeUnit
+ ? targetSizeToBytes(Number(targetSizeInput.value), targetSizeUnit.value)
+ : undefined;
+ const files = [...state.files];
+
try {
- if (state.files.length === 0) {
- showAlert('No Files', 'Please select at least one PDF file.');
- hideLoader();
- return;
- }
-
- // Check WASM availability for Condense mode
- const algorithm = (
- document.getElementById('compression-algorithm') as HTMLSelectElement
- ).value;
- if (algorithm === 'condense' && !isPyMuPDFAvailable()) {
- showWasmRequiredDialog('pymupdf');
- return;
- }
-
- if (state.files.length === 1) {
- const originalFile = state.files[0];
-
- let resultBlob: Blob;
- let resultSize: number;
- let usedMethod: string;
-
- if (algorithm === 'condense') {
- showLoader('Running Condense compression...');
- const result = await performCondenseCompression(
- originalFile,
- level,
- customSettings
- );
- resultBlob = result.blob;
- resultSize = result.compressedSize;
- usedMethod = 'Condense';
-
- // Check if fallback was used
- if ((result as { usedFallback?: boolean }).usedFallback) {
- usedMethod +=
- ' (without image optimization due to unsupported patterns)';
- }
- } else {
- showLoader('Running Photon compression...');
- const arrayBuffer = (await readFileAsArrayBuffer(
- originalFile
- )) as ArrayBuffer;
- const resultBytes = await performPhotonCompression(
- arrayBuffer,
- level,
- originalFile
- );
- if (!resultBytes) return;
- const buffer = resultBytes.buffer.slice(
- resultBytes.byteOffset,
- resultBytes.byteOffset + resultBytes.byteLength
- ) as ArrayBuffer;
- resultBlob = new Blob([buffer], { type: 'application/pdf' });
- resultSize = resultBytes.length;
- usedMethod = 'Photon';
- }
-
- const originalSize = formatBytes(originalFile.size);
- const compressedSize = formatBytes(resultSize);
- const savings = originalFile.size - resultSize;
- const savingsPercent =
- savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0;
-
- downloadFile(resultBlob, originalFile.name);
-
- hideLoader();
-
- if (savings > 0) {
- showAlert(
- 'Compression Complete',
- `Method: ${usedMethod}. File size reduced from ${originalSize} to ${compressedSize} (Saved ${savingsPercent}%).`,
- 'success',
- () => resetState()
- );
- } else {
- showAlert(
- 'Compression Finished',
- `Method: ${usedMethod}. Could not reduce file size further. Original: ${originalSize}, New: ${compressedSize}.`,
- 'warning',
- () => resetState()
- );
- }
+ if (files.length === 1) {
+ await compressSingle(files[0], runOptions, targetBytes);
} else {
- showLoader('Compressing multiple PDFs...');
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
- let totalOriginalSize = 0;
- let totalCompressedSize = 0;
-
- for (let i = 0; i < state.files.length; i++) {
- const file = state.files[i];
- showLoader(
- `Compressing ${i + 1}/${state.files.length}: ${file.name}...`
- );
- totalOriginalSize += file.size;
-
- let resultBytes: Uint8Array;
- if (algorithm === 'condense') {
- const result = await performCondenseCompression(
- file,
- level,
- customSettings
- );
- resultBytes = new Uint8Array(await result.blob.arrayBuffer());
- } else {
- const arrayBuffer = (await readFileAsArrayBuffer(
- file
- )) as ArrayBuffer;
- const photonResult = await performPhotonCompression(
- arrayBuffer,
- level,
- file
- );
- if (!photonResult) return;
- resultBytes = photonResult;
- }
-
- totalCompressedSize += resultBytes.length;
- zip.file(file.name, resultBytes);
- }
-
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- const totalSavings = totalOriginalSize - totalCompressedSize;
- const totalSavingsPercent =
- totalSavings > 0
- ? ((totalSavings / totalOriginalSize) * 100).toFixed(1)
- : 0;
-
- downloadFile(zipBlob, 'compressed-pdfs.zip');
-
- hideLoader();
-
- if (totalSavings > 0) {
- showAlert(
- 'Compression Complete',
- `Compressed ${state.files.length} PDF(s). Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${totalSavingsPercent}%).`,
- 'success',
- () => resetState()
- );
- } else {
- showAlert(
- 'Compression Finished',
- `Compressed ${state.files.length} PDF(s). Total size: ${formatBytes(totalCompressedSize)}.`,
- 'info',
- () => resetState()
- );
- }
+ await compressBatch(files, runOptions, targetBytes);
}
} catch (e: unknown) {
hideLoader();
+ if (e instanceof HyperError && e.code === 'decrypt_failed') {
+ const file = files[0];
+ const lock = fileLocks.get(file);
+ if (lock) {
+ lock.attempt++;
+ lock.status = lock.password ? 'wrong' : 'locked';
+ }
+ updateUI();
+ showAlert(
+ 'Password Needed',
+ `${file.name} is password protected. Enter its password on the file card, then compress again.`,
+ 'warning',
+ () => lockViews.get(file)?.input.focus()
+ );
+ return;
+ }
console.error('[CompressPDF] Error:', e);
showAlert(
'Error',
@@ -556,10 +1046,42 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
- const handleFileSelect = (files: FileList | null) => {
+ const handleFileSelect = async (files: ArrayLike | null) => {
if (files && files.length > 0) {
- state.files = [...state.files, ...Array.from(files)];
+ const added: File[] = [];
+ const rejected: string[] = [];
+ for (const file of Array.from(files)) {
+ if (hasPdfHeader(await file.slice(0, 1024).arrayBuffer())) {
+ added.push(file);
+ } else {
+ rejected.push(file.name);
+ }
+ }
+ if (rejected.length > 0) {
+ showAlert(
+ 'Not a PDF',
+ rejected.length === 1
+ ? `${rejected[0]} is not a valid PDF, so it was not added.`
+ : `These files are not valid PDFs, so they were not added: ${rejected.join(', ')}.`,
+ 'warning'
+ );
+ }
+ if (added.length === 0) return;
+ for (const file of added) {
+ fileLocks.set(file, { status: 'checking', password: '', attempt: 0 });
+ }
+ state.files = [...state.files, ...added];
updateUI();
+
+ for (const file of added) {
+ const lock = fileLocks.get(file);
+ if (!lock) continue;
+ const opens = await tryPassword(file, '');
+ if (fileLocks.get(file) !== lock || lock.status !== 'checking')
+ continue;
+ lock.status = opens === false ? 'locked' : 'open';
+ if (lock.status === 'locked') updateUI();
+ }
}
};
@@ -584,13 +1106,11 @@ document.addEventListener('DOMContentLoaded', () => {
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(
- (f) => f.type === 'application/pdf'
+ (f) =>
+ f.type === 'application/pdf' ||
+ f.name.toLowerCase().endsWith('.pdf')
);
- if (pdfFiles.length > 0) {
- const dataTransfer = new DataTransfer();
- pdfFiles.forEach((f) => dataTransfer.items.add(f));
- handleFileSelect(dataTransfer.files);
- }
+ handleFileSelect(pdfFiles);
}
});
@@ -614,4 +1134,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (processBtn) {
processBtn.addEventListener('click', compress);
}
+
+ applyDefaults();
+ createIcons({ icons });
});
diff --git a/src/js/logic/pdf-to-word-page.ts b/src/js/logic/pdf-to-word-page.ts
index 49b9a8fb..c79e6e45 100644
--- a/src/js/logic/pdf-to-word-page.ts
+++ b/src/js/logic/pdf-to-word-page.ts
@@ -9,6 +9,7 @@ import {
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { loadPyMuPDF } from '../utils/pymupdf-loader.js';
+import { normalizeCmykForDocx } from '../utils/ghostscript-loader.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { deduplicateFileName } from '../utils/deduplicate-filename.js';
@@ -112,7 +113,9 @@ document.addEventListener('DOMContentLoaded', () => {
const file = state.files[0];
showLoader(`Converting ${file.name}...`);
- const docxBlob = await pymupdf.pdfToDocx(file);
+ const docxBlob = await pymupdf.pdfToDocx(
+ await normalizeCmykForDocx(file)
+ );
const outName = file.name.replace(/\.pdf$/i, '') + '.docx';
downloadFile(docxBlob, outName);
@@ -135,7 +138,9 @@ document.addEventListener('DOMContentLoaded', () => {
`Converting ${i + 1}/${state.files.length}: ${file.name}...`
);
- const docxBlob = await pymupdf.pdfToDocx(file);
+ const docxBlob = await pymupdf.pdfToDocx(
+ await normalizeCmykForDocx(file)
+ );
const baseName = file.name.replace(/\.pdf$/i, '');
const arrayBuffer = await docxBlob.arrayBuffer();
const zipEntryName = deduplicateFileName(
diff --git a/src/js/logic/pdf-workflow-page.ts b/src/js/logic/pdf-workflow-page.ts
index f4b8630c..09b200f0 100644
--- a/src/js/logic/pdf-workflow-page.ts
+++ b/src/js/logic/pdf-workflow-page.ts
@@ -1134,6 +1134,8 @@ function showNodeSettings(node: BaseWorkflowNode) {
y0: 'Top edge in points',
x1: 'Right edge in points',
y1: 'Bottom edge in points',
+ targetKilobytes:
+ 'Finds the best quality under this size. 0 means no target.',
retainPageLabels:
"Off (default): natural 1–N numbering. On: each file's original page labels are preserved (may produce duplicate labels).",
};
diff --git a/src/js/types/compress-pdf-type.ts b/src/js/types/compress-pdf-type.ts
new file mode 100644
index 00000000..ba2b2fc7
--- /dev/null
+++ b/src/js/types/compress-pdf-type.ts
@@ -0,0 +1,58 @@
+import type {
+ HyperCompressOptions,
+ HyperErrorCode,
+ HyperWasmOptions,
+ HyperWasmResult,
+} from 'hyper-compress-wasm';
+
+export type HyperCompressRequest =
+ | {
+ id: number;
+ type: 'compress';
+ bytes: ArrayBuffer;
+ options: HyperWasmOptions;
+ }
+ | { id: number; type: 'verify'; bytes: ArrayBuffer; password: string };
+
+export type HyperCompressResponse =
+ | { id: number; type: 'result'; result: HyperWasmResult }
+ | { id: number; type: 'verified'; valid: boolean }
+ | { id: number; type: 'error'; code: HyperErrorCode; message: string };
+
+export type CompressLockStatus =
+ | 'checking'
+ | 'open'
+ | 'locked'
+ | 'wrong'
+ | 'unlocked';
+
+export interface CompressFileLock {
+ status: CompressLockStatus;
+ password: string;
+ attempt: number;
+}
+
+export interface CompressPasswordField {
+ root: HTMLElement;
+ input: HTMLInputElement;
+ status: HTMLElement;
+ lockIcon: HTMLElement;
+ unlockIcon: HTMLElement;
+}
+
+interface HyperOptionFieldBase {
+ key: keyof HyperCompressOptions;
+ label: string;
+ help?: string;
+}
+
+export type HyperOptionField =
+ | (HyperOptionFieldBase & { type: 'bool' })
+ | (HyperOptionFieldBase & { type: 'number'; min: number; max: number })
+ | (HyperOptionFieldBase & { type: 'dpi' });
+
+export interface HyperOptionSection {
+ title: string;
+ open?: boolean;
+ fields: HyperOptionField[];
+}
diff --git a/src/js/types/index.ts b/src/js/types/index.ts
index 2bd180d0..1a9624af 100644
--- a/src/js/types/index.ts
+++ b/src/js/types/index.ts
@@ -70,3 +70,4 @@ export * from './workflow-control-spec-type.ts';
export * from './edit-pdf-redaction-type.ts';
export * from './validate-signature-extraction-type.ts';
export * from './pdf-content-tokens-type.ts';
+export * from './compress-pdf-type.ts';
diff --git a/src/js/utils/compress.ts b/src/js/utils/compress.ts
index d933406f..4f51414f 100644
--- a/src/js/utils/compress.ts
+++ b/src/js/utils/compress.ts
@@ -1,161 +1,188 @@
-import { PDFDocument } from 'pdf-lib';
-import { getPDFDocument } from './helpers.js';
-import { loadPyMuPDF } from './pymupdf-loader.js';
+import {
+ HYPER_PRESETS,
+ HyperError,
+ normalizeHyperOptions,
+} from 'hyper-compress-wasm';
+import type {
+ CompressLevel,
+ HyperCompressOptions,
+ HyperWasmOptions,
+ HyperWasmResult,
+} from 'hyper-compress-wasm';
+import type { HyperCompressRequest, HyperCompressResponse } from '@/types';
-export const CONDENSE_PRESETS = {
- light: {
- images: { quality: 90, dpiTarget: 150, dpiThreshold: 200 },
- scrub: { metadata: false, thumbnails: true },
- subsetFonts: true,
- },
- balanced: {
- images: { quality: 75, dpiTarget: 96, dpiThreshold: 150 },
- scrub: { metadata: true, thumbnails: true },
- subsetFonts: true,
- },
- aggressive: {
- images: { quality: 50, dpiTarget: 72, dpiThreshold: 100 },
- scrub: { metadata: true, thumbnails: true, xmlMetadata: true },
- subsetFonts: true,
- },
- extreme: {
- images: { quality: 30, dpiTarget: 60, dpiThreshold: 96 },
- scrub: { metadata: true, thumbnails: true, xmlMetadata: true },
- subsetFonts: true,
- },
+export const COMPRESS_LEVELS: readonly CompressLevel[] = [
+ 'low',
+ 'medium',
+ 'high',
+ 'lossless',
+];
+
+const LEGACY_LEVELS: Record = {
+ light: 'low',
+ balanced: 'medium',
+ aggressive: 'high',
+ extreme: 'high',
};
-export const PHOTON_PRESETS = {
- light: { scale: 2.0, quality: 0.85 },
- balanced: { scale: 1.5, quality: 0.65 },
- aggressive: { scale: 1.2, quality: 0.45 },
- extreme: { scale: 1.0, quality: 0.25 },
-};
+const PRESET_INDEPENDENT_OPTIONS: ReadonlySet =
+ new Set([
+ 'grayscale',
+ 'preserveConformance',
+ 'brotli',
+ 'rasterizeText',
+ 'removeSignatures',
+ ]);
-export interface CondenseCustomSettings {
- imageQuality?: number;
- dpiTarget?: number;
- dpiThreshold?: number;
- removeMetadata?: boolean;
- subsetFonts?: boolean;
- convertToGrayscale?: boolean;
- removeThumbnails?: boolean;
+export function resolveCompressLevel(value: string): CompressLevel {
+ return (
+ COMPRESS_LEVELS.find((level) => level === value) ??
+ (Object.hasOwn(LEGACY_LEVELS, value) ? LEGACY_LEVELS[value] : 'medium')
+ );
}
-export async function performCondenseCompression(
- fileBlob: Blob,
- level: string,
- customSettings?: CondenseCustomSettings
-) {
- const pymupdf = await loadPyMuPDF();
+export function applyHyperPreset(
+ level: CompressLevel,
+ current: HyperCompressOptions
+): HyperCompressOptions {
+ return normalizeHyperOptions({
+ ...HYPER_PRESETS[level],
+ grayscale: current.grayscale,
+ preserveConformance: current.preserveConformance,
+ brotli: current.brotli,
+ rasterizeText: current.rasterizeText,
+ removeSignatures: current.removeSignatures,
+ });
+}
- const preset =
- CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] ||
- CONDENSE_PRESETS.balanced;
+export function matchHyperPreset(
+ options: HyperCompressOptions
+): CompressLevel | null {
+ return (
+ COMPRESS_LEVELS.find((level) => {
+ const preset = normalizeHyperOptions(HYPER_PRESETS[level]);
+ return (Object.keys(preset) as (keyof HyperCompressOptions)[]).every(
+ (key) =>
+ PRESET_INDEPENDENT_OPTIONS.has(key) || preset[key] === options[key]
+ );
+ }) ?? null
+ );
+}
- const dpiTarget = customSettings?.dpiTarget ?? preset.images.dpiTarget;
- const userThreshold =
- customSettings?.dpiThreshold ?? preset.images.dpiThreshold;
- const dpiThreshold = Math.max(userThreshold, dpiTarget + 10);
+export function hasPdfHeader(head: ArrayBuffer): boolean {
+ const bytes = new Uint8Array(head, 0, Math.min(head.byteLength, 1024));
+ return new TextDecoder('latin1').decode(bytes).includes('%PDF-');
+}
- const options = {
- images: {
- enabled: true,
- quality: customSettings?.imageQuality ?? preset.images.quality,
- dpiTarget,
- dpiThreshold,
- convertToGray: customSettings?.convertToGrayscale ?? false,
- },
- scrub: {
- metadata: customSettings?.removeMetadata ?? preset.scrub.metadata,
- thumbnails: customSettings?.removeThumbnails ?? preset.scrub.thumbnails,
- xmlMetadata:
- ('xmlMetadata' in preset.scrub
- ? (preset.scrub as { xmlMetadata?: boolean }).xmlMetadata
- : undefined) ?? false,
- },
- subsetFonts: customSettings?.subsetFonts ?? preset.subsetFonts,
- save: {
- garbage: 4 as const,
- deflate: true,
- clean: true,
- useObjstms: true,
- },
- };
+export function targetSizeToBytes(
+ value: number,
+ unit: string
+): number | undefined {
+ if (!Number.isFinite(value) || value <= 0) return undefined;
+ const bytes = Math.floor(value * (unit === 'kb' ? 1024 : 1024 * 1024));
+ return bytes >= 1 ? bytes : undefined;
+}
- try {
- const result = await pymupdf.compressPdf(fileBlob, options);
- return result;
- } catch {
- const fallbackOptions = {
- ...options,
- images: {
- ...options.images,
- enabled: false,
- },
- };
-
- try {
- const result = await pymupdf.compressPdf(fileBlob, fallbackOptions);
- return { ...result, usedFallback: true };
- } catch (fallbackError: unknown) {
- const msg =
- fallbackError instanceof Error
- ? fallbackError.message
- : String(fallbackError);
- throw new Error(`PDF compression failed: ${msg}`, {
- cause: fallbackError,
- });
- }
+let hyperWorker: Worker | null = null;
+let nextHyperJobId = 0;
+const hyperJobs = new Map<
+ number,
+ {
+ resolve: (message: HyperCompressResponse) => void;
+ reject: (err: Error) => void;
}
+>();
+
+function discardHyperWorker(worker: Worker, error?: Error): void {
+ worker.terminate();
+ if (hyperWorker === worker) hyperWorker = null;
+ if (!error) return;
+ for (const job of hyperJobs.values()) job.reject(error);
+ hyperJobs.clear();
}
-export async function performPhotonCompression(
- arrayBuffer: ArrayBuffer,
- level: string
-): Promise {
- const pdfJsDoc = await getPDFDocument({ data: arrayBuffer }).promise;
- const newPdfDoc = await PDFDocument.create();
- const settings =
- PHOTON_PRESETS[level as keyof typeof PHOTON_PRESETS] ||
- PHOTON_PRESETS.balanced;
-
- for (let i = 1; i <= pdfJsDoc.numPages; i++) {
- const page = await pdfJsDoc.getPage(i);
- const viewport = page.getViewport({ scale: settings.scale });
- const canvas = document.createElement('canvas');
- const context = canvas.getContext('2d');
- if (!context) throw new Error('Failed to create canvas context');
- canvas.height = viewport.height;
- canvas.width = viewport.width;
-
- await page.render({ canvasContext: context, viewport, canvas: canvas })
- .promise;
-
- const jpegBlob = await new Promise((resolve, reject) =>
- canvas.toBlob(
- (blob) => {
- if (blob) resolve(blob);
- else reject(new Error('Failed to create JPEG blob'));
- },
- 'image/jpeg',
- settings.quality
+function getHyperWorker(): Worker {
+ if (hyperWorker) return hyperWorker;
+ const worker = new Worker(
+ new URL('./hyper-compress.worker.ts', import.meta.url),
+ { type: 'module' }
+ );
+ worker.onmessage = (e: MessageEvent) => {
+ const message = e.data;
+ const job = hyperJobs.get(message.id);
+ if (!job) return;
+ hyperJobs.delete(message.id);
+ if (message.type !== 'error') {
+ job.resolve(message);
+ return;
+ }
+ job.reject(new HyperError(message.code, message.message));
+ if (message.code === 'engine_error' && hyperJobs.size === 0) {
+ discardHyperWorker(worker);
+ }
+ };
+ worker.onerror = (e: ErrorEvent) => {
+ e.preventDefault();
+ discardHyperWorker(
+ worker,
+ new HyperError(
+ 'engine_error',
+ e.message || 'The compression engine stopped unexpectedly.'
)
);
+ };
+ worker.onmessageerror = () => {
+ discardHyperWorker(
+ worker,
+ new HyperError(
+ 'engine_error',
+ 'The compression engine returned an unreadable result.'
+ )
+ );
+ };
+ hyperWorker = worker;
+ return worker;
+}
- // Release canvas memory
- canvas.width = 0;
- canvas.height = 0;
+function postToHyper(
+ build: (id: number) => HyperCompressRequest,
+ transfer: ArrayBuffer
+): Promise {
+ const worker = getHyperWorker();
+ const id = ++nextHyperJobId;
+ return new Promise((resolve, reject) => {
+ hyperJobs.set(id, { resolve, reject });
+ worker.postMessage(build(id), [transfer]);
+ });
+}
- const jpegBytes = await jpegBlob.arrayBuffer();
- const jpegImage = await newPdfDoc.embedJpg(jpegBytes);
- const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
- newPage.drawImage(jpegImage, {
- x: 0,
- y: 0,
- width: viewport.width,
- height: viewport.height,
- });
+export async function compressWithHyper(
+ pdf: ArrayBuffer,
+ options: HyperWasmOptions
+): Promise {
+ if (!hasPdfHeader(pdf)) {
+ throw new HyperError('engine_error', 'This file is not a valid PDF.');
}
- return await newPdfDoc.save();
+ const message = await postToHyper(
+ (id) => ({ id, type: 'compress', bytes: pdf, options }),
+ pdf
+ );
+ if (message.type !== 'result') {
+ throw new HyperError('engine_error', 'Unexpected reply from the engine.');
+ }
+ return message.result;
+}
+
+export async function verifyHyperPassword(
+ pdf: ArrayBuffer,
+ password: string
+): Promise {
+ const message = await postToHyper(
+ (id) => ({ id, type: 'verify', bytes: pdf, password }),
+ pdf
+ );
+ if (message.type !== 'verified') {
+ throw new HyperError('engine_error', 'Unexpected reply from the engine.');
+ }
+ return message.valid;
}
diff --git a/src/js/utils/ghostscript-loader.ts b/src/js/utils/ghostscript-loader.ts
index 76cd011f..bf1c6b83 100644
--- a/src/js/utils/ghostscript-loader.ts
+++ b/src/js/utils/ghostscript-loader.ts
@@ -487,3 +487,81 @@ export async function convertFileToOutlines(
copy.set(result);
return new Blob([copy], { type: 'application/pdf' });
}
+
+const CMYK_COLOR_SPACES = /\/DeviceCMYK|\/DeviceN\b|\/Separation\b/;
+const FOUR_CHANNELS = /\/N\s+4\b/g;
+const COUNT_DICTIONARIES = /\/ObjStm|\/Linearized/;
+
+const RGB_ARGS = [
+ '-dSAFER',
+ '-dBATCH',
+ '-dNOPAUSE',
+ '-dQUIET',
+ '-sDEVICE=pdfwrite',
+ '-sColorConversionStrategy=sRGB',
+ '-sColorConversionStrategyForImages=sRGB',
+ '-dConvertCMYKImagesToRGB=true',
+ '-dProcessColorModel=/DeviceRGB',
+ '-dAutoFilterColorImages=true',
+ '-dAutoFilterGrayImages=true',
+ '-dColorImageFilter=/DCTEncode',
+ '-dGrayImageFilter=/DCTEncode',
+ '-dCompatibilityLevel=1.4',
+];
+
+export function pdfUsesCmyk(pdfData: Uint8Array): boolean {
+ const text = new TextDecoder('latin1').decode(pdfData);
+ if (CMYK_COLOR_SPACES.test(text)) return true;
+ for (const match of text.matchAll(FOUR_CHANNELS)) {
+ const start = text.lastIndexOf('<<', match.index);
+ const end = text.indexOf('>>', match.index);
+ if (!COUNT_DICTIONARIES.test(text.slice(start, end))) return true;
+ }
+ return false;
+}
+
+export async function convertPdfToRgb(
+ pdfData: Uint8Array
+): Promise {
+ const gs = cachedGsModule ?? (cachedGsModule = await loadGsModule());
+ const inputPath = '/tmp/rgb-input.pdf';
+ const outputPath = '/tmp/rgb-output.pdf';
+ gs.FS.writeFile(inputPath, pdfData);
+ try {
+ const exitCode = gs.callMain([
+ ...RGB_ARGS,
+ `-sOutputFile=${outputPath}`,
+ inputPath,
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(
+ `Ghostscript RGB conversion failed with exit code ${exitCode}`
+ );
+ }
+ return gs.FS.readFile(outputPath);
+ } finally {
+ for (const path of [inputPath, outputPath]) {
+ try {
+ gs.FS.unlink(path);
+ } catch {
+ continue;
+ }
+ }
+ }
+}
+
+export async function normalizeCmykForDocx(pdf: Blob): Promise {
+ if (!isWasmAvailable('ghostscript')) return pdf;
+ const pdfData = new Uint8Array(await pdf.arrayBuffer());
+ if (!pdfUsesCmyk(pdfData)) return pdf;
+ try {
+ const rgb = await convertPdfToRgb(pdfData);
+ return new Blob([new Uint8Array(rgb)], { type: 'application/pdf' });
+ } catch (e) {
+ console.warn(
+ '[Ghostscript] RGB conversion failed, converting the original PDF:',
+ e
+ );
+ return pdf;
+ }
+}
diff --git a/src/js/utils/hyper-compress.worker.ts b/src/js/utils/hyper-compress.worker.ts
new file mode 100644
index 00000000..51e9dc8f
--- /dev/null
+++ b/src/js/utils/hyper-compress.worker.ts
@@ -0,0 +1,59 @@
+import { compress, verifyPassword, HyperError } from 'hyper-compress-wasm';
+import type { HyperCompressRequest, HyperCompressResponse } from '@/types';
+
+const ENCRYPT_KEY = new TextEncoder().encode('/Encrypt');
+
+function hasEncryptKey(bytes: Uint8Array): boolean {
+ for (
+ let i = bytes.indexOf(ENCRYPT_KEY[0]);
+ i !== -1;
+ i = bytes.indexOf(ENCRYPT_KEY[0], i + 1)
+ ) {
+ if (ENCRYPT_KEY.every((byte, j) => bytes[i + j] === byte)) return true;
+ }
+ return false;
+}
+
+async function opensWith(
+ input: Uint8Array,
+ password: string
+): Promise {
+ return !hasEncryptKey(input) || verifyPassword(input, password);
+}
+
+self.onmessage = async (e: MessageEvent) => {
+ const request = e.data;
+ const { id } = request;
+ try {
+ const input = new Uint8Array(request.bytes);
+ if (request.type === 'verify') {
+ const response: HyperCompressResponse = {
+ id,
+ type: 'verified',
+ valid: await opensWith(input, request.password),
+ };
+ self.postMessage(response);
+ return;
+ }
+ const { options } = request;
+ const result = await compress(input, options);
+ if (
+ !result.signed &&
+ result.compressedSize === result.originalSize &&
+ !options.password &&
+ !(await opensWith(input, ''))
+ ) {
+ throw new HyperError('decrypt_failed', 'wrong or missing password');
+ }
+ const response: HyperCompressResponse = { id, type: 'result', result };
+ self.postMessage(response, { transfer: [result.data.buffer] });
+ } catch (err) {
+ const response: HyperCompressResponse = {
+ id,
+ type: 'error',
+ code: err instanceof HyperError ? err.code : 'engine_error',
+ message: err instanceof Error ? err.message : String(err),
+ };
+ self.postMessage(response);
+ }
+};
diff --git a/src/js/utils/pymupdf-loader.ts b/src/js/utils/pymupdf-loader.ts
index a4243834..0251204b 100644
--- a/src/js/utils/pymupdf-loader.ts
+++ b/src/js/utils/pymupdf-loader.ts
@@ -50,7 +50,6 @@ export async function loadPyMuPDF(): Promise {
}
const pymupdfUrl = WasmProvider.getUrl('pymupdf')!;
- const gsUrl = WasmProvider.getUrl('ghostscript')!;
const normalizedPymupdf = pymupdfUrl.endsWith('/')
? pymupdfUrl
: `${pymupdfUrl}/`;
@@ -67,7 +66,6 @@ export async function loadPyMuPDF(): Promise {
cachedPyMuPDF = new module.PyMuPDF({
assetPath: `${normalizedPymupdf}assets/`,
- ghostscriptUrl: gsUrl,
});
await cachedPyMuPDF.load();
diff --git a/src/js/utils/wasm-provider.ts b/src/js/utils/wasm-provider.ts
index df102070..d3decaa7 100644
--- a/src/js/utils/wasm-provider.ts
+++ b/src/js/utils/wasm-provider.ts
@@ -56,6 +56,16 @@ function collectBuiltinTrustedHosts(): Set {
const BUILTIN_TRUSTED_HOSTS = collectBuiltinTrustedHosts();
+function isOutdatedCdnPin(packageName: WasmPackage, url: string): boolean {
+ const current = CDN_DEFAULTS[packageName];
+ const packagePrefix = current.slice(0, current.lastIndexOf('@') + 1);
+ return url !== current && url.startsWith(packagePrefix);
+}
+
+function withTrailingSlash(url: string): string {
+ return url.endsWith('/') ? url : `${url}/`;
+}
+
class WasmProviderManager {
private config: WasmProviderConfig;
private validationCache: Map = new Map();
@@ -80,7 +90,12 @@ class WasmProviderManager {
for (const key of ['pymupdf', 'ghostscript', 'cpdf'] as WasmPackage[]) {
const url = parsed[key];
if (typeof url !== 'string') continue;
- if (this.isTrustedUrl(url)) {
+ if (isOutdatedCdnPin(key, url)) {
+ dropped = true;
+ console.warn(
+ `[WasmProvider] Ignoring outdated stored URL for ${key}: ${url}. Using the current default instead.`
+ );
+ } else if (this.isTrustedUrl(url)) {
safe[key] = url;
} else {
dropped = true;
@@ -134,11 +149,16 @@ class WasmProviderManager {
}
setUrl(packageName: WasmPackage, url: string): void {
- const normalizedUrl = url.endsWith('/') ? url : `${url}/`;
+ const normalizedUrl = withTrailingSlash(url);
const host = hostnameOf(normalizedUrl);
if (!host) {
throw new Error('Invalid URL');
}
+ const envDefault = this.getEnvDefault(packageName);
+ if (envDefault && normalizedUrl === withTrailingSlash(envDefault)) {
+ this.removeUrl(packageName);
+ return;
+ }
this.trustedHosts.add(host);
this.config[packageName] = normalizedUrl;
this.validationCache.delete(packageName);
@@ -321,7 +341,6 @@ class WasmProviderManager {
'Extract Images',
'Extract Tables',
'EPUB/MOBI/FB2/XPS/CBZ to PDF',
- 'Image Compression',
'Deskew PDF',
'PDF Layers',
],
diff --git a/src/js/workflow/nodes/compress-node.ts b/src/js/workflow/nodes/compress-node.ts
index 0f82b44d..e5adf3fa 100644
--- a/src/js/workflow/nodes/compress-node.ts
+++ b/src/js/workflow/nodes/compress-node.ts
@@ -4,11 +4,10 @@ import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import {
- performCondenseCompression,
- performPhotonCompression,
+ compressWithHyper,
+ resolveCompressLevel,
+ targetSizeToBytes,
} from '../../utils/compress.js';
-import type { CondenseCustomSettings } from '../../utils/compress.js';
-import { isPyMuPDFAvailable } from '../../utils/pymupdf-loader.js';
import { loadPdfDocument } from '../../utils/load-pdf-document.js';
export class CompressNode extends BaseWorkflowNode {
@@ -23,42 +22,25 @@ export class CompressNode extends BaseWorkflowNode {
'pdf',
new ClassicPreset.Output(pdfSocket, 'Compressed PDF')
);
- this.addControl(
- 'algorithm',
- new ClassicPreset.InputControl('text', { initial: 'condense' })
- );
this.addControl(
'compressionLevel',
- new ClassicPreset.InputControl('text', { initial: 'balanced' })
+ new ClassicPreset.InputControl('text', { initial: 'medium' })
);
this.addControl(
- 'imageQuality',
- new ClassicPreset.InputControl('number', { initial: 75 })
- );
- this.addControl(
- 'dpiTarget',
- new ClassicPreset.InputControl('number', { initial: 96 })
- );
- this.addControl(
- 'dpiThreshold',
- new ClassicPreset.InputControl('number', { initial: 150 })
- );
- this.addControl(
- 'removeMetadata',
- new ClassicPreset.InputControl('text', { initial: 'true' })
- );
- this.addControl(
- 'subsetFonts',
- new ClassicPreset.InputControl('text', { initial: 'true' })
+ 'targetKilobytes',
+ new ClassicPreset.InputControl('number', { initial: 0 })
);
this.addControl(
'convertToGrayscale',
new ClassicPreset.InputControl('text', { initial: 'false' })
);
- this.addControl(
- 'removeThumbnails',
- new ClassicPreset.InputControl('text', { initial: 'true' })
- );
+ }
+
+ sanitizeControlValue(key: string, value: unknown): unknown {
+ if (key === 'compressionLevel' && typeof value === 'string') {
+ return super.sanitizeControlValue(key, resolveCompressLevel(value));
+ }
+ return super.sanitizeControlValue(key, value);
}
private getTextControl(key: string, fallback: string): string {
@@ -80,41 +62,24 @@ export class CompressNode extends BaseWorkflowNode {
): Promise> {
const pdfInputs = requirePdfInput(inputs, 'Compress');
- const algorithm = this.getTextControl('algorithm', 'condense');
- const level = this.getTextControl('compressionLevel', 'balanced');
- const customSettings: CondenseCustomSettings = {
- imageQuality: this.getNumberControl('imageQuality', 75),
- dpiTarget: this.getNumberControl('dpiTarget', 96),
- dpiThreshold: this.getNumberControl('dpiThreshold', 150),
- removeMetadata: this.getTextControl('removeMetadata', 'true') === 'true',
- subsetFonts: this.getTextControl('subsetFonts', 'true') === 'true',
- convertToGrayscale:
- this.getTextControl('convertToGrayscale', 'false') === 'true',
- removeThumbnails:
- this.getTextControl('removeThumbnails', 'true') === 'true',
- };
+ const level = resolveCompressLevel(
+ this.getTextControl('compressionLevel', 'medium')
+ );
+ const grayscale =
+ this.getTextControl('convertToGrayscale', 'false') === 'true';
+ const targetSizeBytes = targetSizeToBytes(
+ this.getNumberControl('targetKilobytes', 0),
+ 'kb'
+ );
return {
pdf: await processBatch(pdfInputs, async (input) => {
- const arrayBuffer = input.bytes.buffer.slice(
- input.bytes.byteOffset,
- input.bytes.byteOffset + input.bytes.byteLength
- ) as ArrayBuffer;
-
- let pdfBytes: Uint8Array;
-
- if (algorithm === 'condense' && isPyMuPDFAvailable()) {
- const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
- const result = await performCondenseCompression(
- blob,
- level,
- customSettings
- );
- pdfBytes = new Uint8Array(await result.blob.arrayBuffer());
- } else {
- pdfBytes = await performPhotonCompression(arrayBuffer, level);
- }
-
+ const result = await compressWithHyper(input.bytes.slice().buffer, {
+ preset: level,
+ options: { grayscale },
+ targetSizeBytes,
+ });
+ const pdfBytes = result.data;
const document = await loadPdfDocument(pdfBytes);
return {
diff --git a/src/js/workflow/nodes/control-specs.ts b/src/js/workflow/nodes/control-specs.ts
index 502f62a3..8d3c347c 100644
--- a/src/js/workflow/nodes/control-specs.ts
+++ b/src/js/workflow/nodes/control-specs.ts
@@ -86,23 +86,14 @@ const CONTROL_SPECS: Record> = {
separatorColor: COLOR,
},
CompressNode: {
- algorithm: selectSpec(
- ['condense', 'Condense (Smart, requires PyMuPDF)'],
- ['photon', 'Photon (Rasterize pages)']
- ),
compressionLevel: selectSpec(
- ['light', 'Light'],
- ['balanced', 'Balanced'],
- ['aggressive', 'Aggressive'],
- ['extreme', 'Extreme']
+ ['low', 'Low (Best Quality)'],
+ ['medium', 'Medium (Recommended)'],
+ ['high', 'High (Smallest Files)'],
+ ['lossless', 'Lossless (No Quality Loss)']
),
- imageQuality: numberSpec(1, 100, 1),
- dpiTarget: numberSpec(36, 300, 1),
- dpiThreshold: numberSpec(72, 600, 1),
- removeMetadata: BOOLEAN,
- subsetFonts: BOOLEAN,
+ targetKilobytes: numberSpec(0, 1048576, 1),
convertToGrayscale: BOOLEAN,
- removeThumbnails: BOOLEAN,
},
CropNode: {
top: PAGE_COORDINATE,
diff --git a/src/js/workflow/nodes/pdf-to-docx-node.ts b/src/js/workflow/nodes/pdf-to-docx-node.ts
index 501aa753..37ad17ac 100644
--- a/src/js/workflow/nodes/pdf-to-docx-node.ts
+++ b/src/js/workflow/nodes/pdf-to-docx-node.ts
@@ -5,6 +5,7 @@ import type { SocketData } from '../types';
import { requirePdfInput, extractAllPdfs } from '../types';
import { downloadFile } from '../../utils/helpers.js';
import { loadPyMuPDF } from '../../utils/pymupdf-loader.js';
+import { normalizeCmykForDocx } from '../../utils/ghostscript-loader.js';
export class PdfToDocxNode extends BaseWorkflowNode {
readonly category = 'Output' as const;
@@ -27,7 +28,9 @@ export class PdfToDocxNode extends BaseWorkflowNode {
const blob = new Blob([new Uint8Array(allPdfs[0].bytes)], {
type: 'application/pdf',
});
- const docxBlob = await pymupdf.pdfToDocx(blob);
+ const docxBlob = await pymupdf.pdfToDocx(
+ await normalizeCmykForDocx(blob)
+ );
const name = allPdfs[0].filename.replace(/\.pdf$/i, '') + '.docx';
downloadFile(docxBlob, name);
} else {
@@ -37,7 +40,9 @@ export class PdfToDocxNode extends BaseWorkflowNode {
const blob = new Blob([new Uint8Array(pdf.bytes)], {
type: 'application/pdf',
});
- const docxBlob = await pymupdf.pdfToDocx(blob);
+ const docxBlob = await pymupdf.pdfToDocx(
+ await normalizeCmykForDocx(blob)
+ );
const name = pdf.filename.replace(/\.pdf$/i, '') + '.docx';
const arrayBuffer = await docxBlob.arrayBuffer();
zip.file(name, arrayBuffer);
diff --git a/src/pages/compress-pdf-for-email.html b/src/pages/compress-pdf-for-email.html
index 36fd7fbb..e61502f7 100644
--- a/src/pages/compress-pdf-for-email.html
+++ b/src/pages/compress-pdf-for-email.html
@@ -182,189 +182,7 @@
-
-
-
-
-
-
- Condense uses advanced compression: removes
- dead-weight, optimizes images, subsets fonts. Best for most
- PDFs.
-
-
- Photon converts pages to images. Use for
- photo-heavy/scanned PDFs.
- ⚠️ Warning: Text will become non-selectable and links will
- stop working.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reduces file size by removing color information
-
To email a large PDF, compress it below your provider's attachment limit
- first: add the file below, pick Balanced, and download the smaller copy
- to attach. The work happens in your browser, so a confidential contract
- never touches a third-party server on its way to your outbox.
+ first: add the file below and click Compress PDF. The target size is set
+ to 10 MB, which fits even strict corporate mail servers; change it to
+ match your provider. The work happens in your browser, so a confidential
+ contract never touches a third-party server on its way to your outbox.
@@ -434,10 +253,10 @@
Cloud links expire, need permissions, and get quarantined by security
- filters. A compressed attachment simply arrives. Balanced level
- typically cuts a scanned contract or slide deck by 60 to 90%, which
- turns most too-big files into comfortable attachments while keeping them
- readable and printable.
+ filters. A compressed attachment simply arrives. The Medium level often
+ cuts a scanned contract or slide deck by 60 to 90%, which turns most
+ too-big files into comfortable attachments while keeping them readable
+ and printable.
@@ -482,10 +301,10 @@
- Choose Algorithm
+ Select Compression Level
- Select Condense (recommended) or Photon (for photo-heavy PDFs)
+ Pick Low, Medium, High, or Lossless compression
@@ -497,10 +316,11 @@
- Select Compression Level
+ Customize & Compress
- Pick Light, Balanced, Aggressive, or Extreme compression
+ Optionally set a target size or adjust custom settings such as
+ grayscale, then compress
@@ -510,21 +330,6 @@
>
4
-
-
- Customize & Compress
-
-
- Optionally enable grayscale or custom settings, then compress
-
-
-
-
-
- 5
-
Download
@@ -641,11 +446,10 @@
},
"featureList": [
"Reduce PDF file size",
- "Multiple compression algorithms",
"Batch processing",
"No file size limits",
- "Condense algorithm for smart compression",
- "Photon algorithm for image-heavy PDFs"
+ "Hyper Compress engine built in",
+ "Compress to a target file size"
]
}
@@ -667,17 +471,11 @@
"@type": "HowToStep",
"position": 2,
"name": "Choose Compression Level",
- "text": "Select your desired compression level: Light, Balanced, Aggressive, or Extreme"
+ "text": "Select your desired compression level: Low, Medium, High, or Lossless"
},
{
"@type": "HowToStep",
"position": 3,
- "name": "Select Algorithm",
- "text": "Choose Condense (recommended) or Photon algorithm based on your PDF type"
- },
- {
- "@type": "HowToStep",
- "position": 4,
"name": "Compress and Download",
"text": "Click 'Compress PDF' and download your optimized file"
}
diff --git a/src/pages/compress-pdf-to-100kb.html b/src/pages/compress-pdf-to-100kb.html
index 65f280f4..8d0c85c9 100644
--- a/src/pages/compress-pdf-to-100kb.html
+++ b/src/pages/compress-pdf-to-100kb.html
@@ -179,189 +179,7 @@
-
-
-
-
-
-
- Condense uses advanced compression: removes
- dead-weight, optimizes images, subsets fonts. Best for most
- PDFs.
-
-
- Photon converts pages to images. Use for
- photo-heavy/scanned PDFs.
- ⚠️ Warning: Text will become non-selectable and links will
- stop working.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reduces file size by removing color information
-
- To compress a PDF to 100KB, add your file below, choose the Aggressive
- or Extreme compression level, and download the smaller file. Everything
- runs inside your browser. the PDF is never uploaded to a server, so even
- sensitive documents like ID scans and application forms stay on your
- device.
+ To compress a PDF to 100KB, add your file below and click Compress PDF.
+ The target size is already set to 100 KB, so Hyper Compress picks the
+ best image quality that still fits. Everything runs inside your browser
+ and the PDF is never uploaded to a server, so even sensitive documents
+ like ID scans and application forms stay on your device.
@@ -433,10 +251,11 @@
A text-based PDF of a few pages. a resume, cover letter, or typed form.
compresses below 100KB easily with no visible change. Scanned documents
and photo-heavy files are harder: to hit 100KB they need stronger image
- downscaling, so expect visibly softer images. If your scan will not
- reach 100KB with the Extreme level, try the Photon mode, which rebuilds
- each page as an optimized image, or split the document and compress the
- pages you actually need to submit.
+ downscaling, so expect visibly softer images. If your scan still will
+ not reach 100KB, the result tells you the smallest size it could make.
+ If the scan has no text layer, turn on Rasterize pages under Custom
+ Settings to rebuild each page as one optimized image, or split the
+ document and compress the pages you actually need to submit.
@@ -481,10 +300,10 @@
- Choose Algorithm
+ Select Compression Level
- Select Condense (recommended) or Photon (for photo-heavy PDFs)
+ Pick Low, Medium, High, or Lossless compression
@@ -496,10 +315,11 @@
- Select Compression Level
+ Customize & Compress
- Pick Light, Balanced, Aggressive, or Extreme compression
+ Optionally set a target size or adjust custom settings such as
+ grayscale, then compress
@@ -509,21 +329,6 @@
>
4
-
-
- Customize & Compress
-
-
- Optionally enable grayscale or custom settings, then compress
-
-
-
-
-
- 5
-
Download
@@ -585,9 +390,9 @@
Not always. Short text-based PDFs almost always can. Long or heavily
- scanned documents may bottom out above 100KB even at Extreme level.
- in that case split the file or rescan at a lower DPI and compress
- again.
+ scanned documents may bottom out above 100KB, and the result message
+ tells you when that happens. In that case split the file or rescan
+ at a lower DPI and compress again.
@@ -598,10 +403,11 @@
>
- Start with Aggressive. If the result is still over 100KB, run
- Extreme, and for scans try Photon mode. You can compress the same
- file repeatedly with different levels. nothing is uploaded, so
- retries are instant.
+ You can leave it on Medium. The 100 KB target is already filled in,
+ so Hyper Compress steps image quality down only as far as it needs
+ to. Pick Low instead if you want it to start from higher quality,
+ and for image-only scans that still won't fit, turn on Rasterize
+ pages. Nothing is uploaded, so retries are instant.
@@ -612,9 +418,10 @@
>
- With the standard Condense engine, yes. text, links, and form fields
- are preserved. Photon mode converts pages to images, so text is no
- longer selectable; use it only when a scan refuses to shrink.
+ Yes. Text, links, and form fields are preserved. Rasterize pages
+ only applies to scans with no text layer, so it never turns
+ selectable text into an image unless you turn on Override content
+ protection.
@@ -652,11 +459,10 @@
},
"featureList": [
"Reduce PDF file size",
- "Multiple compression algorithms",
"Batch processing",
"No file size limits",
- "Condense algorithm for smart compression",
- "Photon algorithm for image-heavy PDFs"
+ "Hyper Compress engine built in",
+ "Compress to a target file size"
]
}
@@ -678,17 +484,11 @@
"@type": "HowToStep",
"position": 2,
"name": "Choose Compression Level",
- "text": "Select your desired compression level: Light, Balanced, Aggressive, or Extreme"
+ "text": "Select your desired compression level: Low, Medium, High, or Lossless"
},
{
"@type": "HowToStep",
"position": 3,
- "name": "Select Algorithm",
- "text": "Choose Condense (recommended) or Photon algorithm based on your PDF type"
- },
- {
- "@type": "HowToStep",
- "position": 4,
"name": "Compress and Download",
"text": "Click 'Compress PDF' and download your optimized file"
}
diff --git a/src/pages/compress-pdf-to-1mb.html b/src/pages/compress-pdf-to-1mb.html
index 53ed044e..f115322d 100644
--- a/src/pages/compress-pdf-to-1mb.html
+++ b/src/pages/compress-pdf-to-1mb.html
@@ -171,189 +171,7 @@
-
-
-
-
-
-
- Condense uses advanced compression: removes
- dead-weight, optimizes images, subsets fonts. Best for most
- PDFs.
-
-
- Photon converts pages to images. Use for
- photo-heavy/scanned PDFs.
- ⚠️ Warning: Text will become non-selectable and links will
- stop working.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reduces file size by removing color information
-
- To compress a PDF to 1MB, add the file below, use the Balanced level,
- and download the smaller copy. One megabyte is the most common upload
- ceiling on the web, and nearly every document reaches it without visible
- quality loss. processed privately in your browser.
+ To compress a PDF to 1MB, add the file below and click Compress PDF. The
+ target size is already set to 1 MB. One megabyte is the most common
+ upload ceiling on the web, nearly every document reaches it without
+ visible quality loss, and everything is processed privately in your
+ browser.
@@ -411,7 +230,7 @@
Countless web forms cap attachments at 1MB: HR systems, insurance claim
uploads, property portals, CRM attachments. Even large reports with
- photos usually compress below 1MB at Balanced level, because most PDF
+ photos usually compress below 1MB at the Medium level, because most PDF
weight is unoptimized images and duplicate embedded fonts that
compression removes losslessly first.
@@ -419,10 +238,11 @@
Very large files
- A 50 to 100MB scanned book or brochure may need the Aggressive level to
- get under 1MB, and extremely long documents may not fit at any quality.
- In that case, split the PDF into logical parts. chapters or sections.
- and compress each part under 1MB.
+ A 50 to 100MB scanned book or brochure needs much stronger image
+ compression to get under 1MB, which the target size handles by lowering
+ quality step by step, and extremely long documents may not fit at any
+ quality. In that case, split the PDF into logical parts, such as
+ chapters or sections, and compress each part under 1MB.
@@ -456,10 +276,10 @@
- Choose Algorithm
+ Select Compression Level
- Select Condense (recommended) or Photon (for photo-heavy PDFs)
+ Pick Low, Medium, High, or Lossless compression
@@ -471,10 +291,11 @@
- Select Compression Level
+ Customize & Compress
- Pick Light, Balanced, Aggressive, or Extreme compression
+ Optionally set a target size or adjust custom settings such as
+ grayscale, then compress
@@ -484,21 +305,6 @@
>
4
-
-
- Customize & Compress
-
-
- Optionally enable grayscale or custom settings, then compress
-
-
-
-
-
- 5
-
Download
@@ -585,9 +391,11 @@
>
- The standard engine preserves form fields. A digitally signed PDF,
- however, will have its signature invalidated by any modification.
- compress before signing, not after.
+ Compression preserves form fields. A digitally signed PDF is left
+ exactly as it is, because any change would invalidate the signature,
+ so compress before signing, not after. If you need it smaller
+ anyway, turn on Override content protection under Custom Settings to
+ remove the signatures and compress it.
@@ -615,11 +423,10 @@
},
"featureList": [
"Reduce PDF file size",
- "Multiple compression algorithms",
"Batch processing",
"No file size limits",
- "Condense algorithm for smart compression",
- "Photon algorithm for image-heavy PDFs"
+ "Hyper Compress engine built in",
+ "Compress to a target file size"
]
}
@@ -641,17 +448,11 @@
"@type": "HowToStep",
"position": 2,
"name": "Choose Compression Level",
- "text": "Select your desired compression level: Light, Balanced, Aggressive, or Extreme"
+ "text": "Select your desired compression level: Low, Medium, High, or Lossless"
},
{
"@type": "HowToStep",
"position": 3,
- "name": "Select Algorithm",
- "text": "Choose Condense (recommended) or Photon algorithm based on your PDF type"
- },
- {
- "@type": "HowToStep",
- "position": 4,
"name": "Compress and Download",
"text": "Click 'Compress PDF' and download your optimized file"
}
diff --git a/src/pages/compress-pdf-to-200kb.html b/src/pages/compress-pdf-to-200kb.html
index cfca913f..037696b1 100644
--- a/src/pages/compress-pdf-to-200kb.html
+++ b/src/pages/compress-pdf-to-200kb.html
@@ -179,189 +179,7 @@
-
-
-
-
-
-
- Condense uses advanced compression: removes
- dead-weight, optimizes images, subsets fonts. Best for most
- PDFs.
-
-
- Photon converts pages to images. Use for
- photo-heavy/scanned PDFs.
- ⚠️ Warning: Text will become non-selectable and links will
- stop working.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reduces file size by removing color information
-
- To get a PDF under 200KB, add the file below, pick the Balanced or
- Aggressive level, and download the result. The compression happens
- locally in your browser. no upload, no signup, no file limit.
+ To get a PDF under 200KB, add the file below and click Compress PDF. The
+ target size is already set to 200 KB, so Hyper Compress finds the best
+ quality that fits. The compression happens locally in your browser, with
+ no upload, no signup, and no file limit.
@@ -418,8 +237,8 @@
A 200KB ceiling shows up on bank KYC uploads, scholarship and exam
registration portals, and many national e-government services. It is a
- friendlier target than 100KB: most everyday documents reach it with the
- Balanced level and no visible quality loss.
+ friendlier target than 100KB: most everyday documents reach it at the
+ Medium level with no visible quality loss.
@@ -427,10 +246,11 @@
Getting a stubborn file under 200KB
- If a scanned document stays above 200KB, step up to Aggressive, which
- downscales images harder while keeping text crisp. Multi-page scans
- benefit from removing blank or irrelevant pages first. every scanned
- page you delete is often 100KB saved before compression even starts.
+ If a scanned document stays above 200KB, the result tells you how close
+ it got. For a scan with no text layer, Rasterize pages under Custom
+ Settings can go smaller. Multi-page scans benefit from removing blank or
+ irrelevant pages first: every scanned page you delete is often 100KB
+ saved before compression even starts.
@@ -464,10 +284,10 @@
- Choose Algorithm
+ Select Compression Level
- Select Condense (recommended) or Photon (for photo-heavy PDFs)
+ Pick Low, Medium, High, or Lossless compression
@@ -479,10 +299,11 @@
- Select Compression Level
+ Customize & Compress
- Pick Light, Balanced, Aggressive, or Extreme compression
+ Optionally set a target size or adjust custom settings such as
+ grayscale, then compress
@@ -492,21 +313,6 @@
>
4
-
-
- Customize & Compress
-
-
- Optionally enable grayscale or custom settings, then compress
-
-
-
-
-
- 5
-
Download
@@ -567,9 +373,8 @@
>
- Typed documents look identical. Scans keep readable text at
- Aggressive level; photos inside the PDF will look slightly softer
- but remain clear on screen.
+ Typed documents look identical. Scans keep readable text; photos
+ inside the PDF will look slightly softer but remain clear on screen.
@@ -580,9 +385,9 @@
>
- Usually yes, if most of the weight is images. A 10MB scan at 300 DPI
- color typically compresses to 150 to 400KB at Aggressive level. If
- it lands just above 200KB, re-run with Extreme.
+ Usually yes, if most of the weight is images. With the 200 KB target
+ set, Hyper Compress keeps lowering image quality and resolution
+ until the file fits, and tells you if it can't get there.
@@ -623,11 +428,10 @@
},
"featureList": [
"Reduce PDF file size",
- "Multiple compression algorithms",
"Batch processing",
"No file size limits",
- "Condense algorithm for smart compression",
- "Photon algorithm for image-heavy PDFs"
+ "Hyper Compress engine built in",
+ "Compress to a target file size"
]
}
@@ -649,17 +453,11 @@
"@type": "HowToStep",
"position": 2,
"name": "Choose Compression Level",
- "text": "Select your desired compression level: Light, Balanced, Aggressive, or Extreme"
+ "text": "Select your desired compression level: Low, Medium, High, or Lossless"
},
{
"@type": "HowToStep",
"position": 3,
- "name": "Select Algorithm",
- "text": "Choose Condense (recommended) or Photon algorithm based on your PDF type"
- },
- {
- "@type": "HowToStep",
- "position": 4,
"name": "Compress and Download",
"text": "Click 'Compress PDF' and download your optimized file"
}
diff --git a/src/pages/compress-pdf-to-2mb.html b/src/pages/compress-pdf-to-2mb.html
index f1e90016..fd2168d5 100644
--- a/src/pages/compress-pdf-to-2mb.html
+++ b/src/pages/compress-pdf-to-2mb.html
@@ -171,189 +171,7 @@
-
-
-
-
-
-
- Condense uses advanced compression: removes
- dead-weight, optimizes images, subsets fonts. Best for most
- PDFs.
-
-
- Photon converts pages to images. Use for
- photo-heavy/scanned PDFs.
- ⚠️ Warning: Text will become non-selectable and links will
- stop working.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reduces file size by removing color information
-
- To compress a PDF to 2MB, add your file below, choose the Light or
- Balanced level, and download the result. A 2MB budget lets you keep high
- image quality. ideal for portfolios and reports. and the compression
- runs entirely on your device.
+ To compress a PDF to 2MB, add your file below, pick the Low level for
+ the best image quality, and click Compress PDF. The target size is
+ already set to 2 MB, a budget that lets you keep high image quality for
+ portfolios and reports, and the compression runs entirely on your
+ device.
@@ -409,9 +228,9 @@
Quality-first compression
- With 2MB to work with, you rarely need destructive settings. The Light
- level removes dead weight. duplicate fonts, unused objects, editing
- leftovers. and keeps images nearly untouched, which suits design
+ With 2MB to work with, you rarely need destructive settings. The Low
+ level removes dead weight, such as duplicate fonts, unused objects, and
+ editing leftovers, and keeps images at high quality, which suits design
portfolios, photography-heavy reports, and print-ready documents that
still have to pass an upload check.
@@ -421,8 +240,9 @@
University thesis submission systems, conference paper uploads, and many
procurement portals cap files at 2MB. Documents exported from Word or
- Google Docs almost always fit after Light compression; InDesign or Canva
- exports with full-resolution imagery may need Balanced.
+ Google Docs almost always fit at the Low level; for InDesign or Canva
+ exports with full-resolution imagery, the 2 MB target lowers image
+ quality only as far as it has to.
@@ -456,10 +276,10 @@
- Choose Algorithm
+ Select Compression Level
- Select Condense (recommended) or Photon (for photo-heavy PDFs)
+ Pick Low, Medium, High, or Lossless compression
@@ -471,10 +291,11 @@
- Select Compression Level
+ Customize & Compress
- Pick Light, Balanced, Aggressive, or Extreme compression
+ Optionally set a target size or adjust custom settings such as
+ grayscale, then compress
@@ -484,21 +305,6 @@
>
4
-
-
- Customize & Compress
-
-
- Optionally enable grayscale or custom settings, then compress
-
-
-
-
-
- 5
-
Download
@@ -559,8 +365,10 @@
>
- Light. It focuses on structural savings and gentle image
- optimization. Use Balanced only if Light leaves you above 2MB.
+ Lossless keeps images exactly as they are, and Low keeps them at
+ high quality. With the 2 MB target set, Hyper Compress starts from
+ the level you pick and lowers image quality only if the file doesn't
+ fit.
@@ -585,8 +393,8 @@
You see the compressed size immediately after processing, and you
- can re-run with a different level as many times as you like. nothing
- is uploaded, so iteration is instant.
+ can re-run with a different level or target as many times as you
+ like. Nothing is uploaded, so iteration is instant.
@@ -614,11 +422,10 @@
},
"featureList": [
"Reduce PDF file size",
- "Multiple compression algorithms",
"Batch processing",
"No file size limits",
- "Condense algorithm for smart compression",
- "Photon algorithm for image-heavy PDFs"
+ "Hyper Compress engine built in",
+ "Compress to a target file size"
]
}
@@ -640,17 +447,11 @@
"@type": "HowToStep",
"position": 2,
"name": "Choose Compression Level",
- "text": "Select your desired compression level: Light, Balanced, Aggressive, or Extreme"
+ "text": "Select your desired compression level: Low, Medium, High, or Lossless"
},
{
"@type": "HowToStep",
"position": 3,
- "name": "Select Algorithm",
- "text": "Choose Condense (recommended) or Photon algorithm based on your PDF type"
- },
- {
- "@type": "HowToStep",
- "position": 4,
"name": "Compress and Download",
"text": "Click 'Compress PDF' and download your optimized file"
}
diff --git a/src/pages/compress-pdf-to-500kb.html b/src/pages/compress-pdf-to-500kb.html
index afd8aee6..208e877e 100644
--- a/src/pages/compress-pdf-to-500kb.html
+++ b/src/pages/compress-pdf-to-500kb.html
@@ -179,189 +179,7 @@
-
-
-
-
-
-
- Condense uses advanced compression: removes
- dead-weight, optimizes images, subsets fonts. Best for most
- PDFs.
-
-
- Photon converts pages to images. Use for
- photo-heavy/scanned PDFs.
- ⚠️ Warning: Text will become non-selectable and links will
- stop working.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reduces file size by removing color information
-
- To compress a PDF to 500KB, add your file below, keep the recommended
- Balanced level, and download the result. 500KB is a comfortable target.
+ To compress a PDF to 500KB, add your file below and click Compress PDF.
+ The target size is already set to 500 KB. It is a comfortable target:
most documents reach it with no visible difference, and your file never
leaves your browser.
@@ -415,10 +233,10 @@
The sweet spot target
- Half a megabyte is the most forgiving of the common upload limits. used
- by CMS uploads, tender portals, and course submission systems. Balanced
- level typically gets a 5 to 20MB office document or light scan under
- 500KB while keeping images sharp and text perfectly crisp.
+ Half a megabyte is the most forgiving of the common upload limits, used
+ by CMS uploads, tender portals, and course submission systems. The
+ Medium level often gets an office document or light scan under 500KB on
+ its own, keeping images sharp and text perfectly crisp.
@@ -426,10 +244,11 @@
If you are just over the line
- A file that lands at 600 to 700KB after Balanced almost always drops
- below 500KB with Aggressive. Check for an embedded photo cover page or a
- high-resolution logo on every page. repeated images are a common hidden
- cost that compression handles well.
+ When the Medium level alone lands just above 500KB, the target size
+ makes Hyper Compress lower image quality a little further until the file
+ fits. Check for an embedded photo cover page or a high-resolution logo
+ on every page: repeated images are a common hidden cost that compression
+ handles well.
@@ -463,10 +282,10 @@
- Choose Algorithm
+ Select Compression Level
- Select Condense (recommended) or Photon (for photo-heavy PDFs)
+ Pick Low, Medium, High, or Lossless compression
@@ -478,10 +297,11 @@
- Select Compression Level
+ Customize & Compress
- Pick Light, Balanced, Aggressive, or Extreme compression
+ Optionally set a target size or adjust custom settings such as
+ grayscale, then compress
@@ -491,21 +311,6 @@
>
4
-
-
- Customize & Compress
-
-
- Optionally enable grayscale or custom settings, then compress
-
-
-
-
-
- 5
-
Download
@@ -566,9 +371,10 @@
>
- At Balanced level, print quality stays excellent for text and vector
- graphics. Photos are optimized for screen-and-print use; only
- large-format printing would show the difference.
+ Print quality stays excellent for text and vector graphics, which
+ compression never degrades. Photos are optimized for
+ screen-and-print use; only large-format printing would show the
+ difference.
@@ -620,11 +426,10 @@
},
"featureList": [
"Reduce PDF file size",
- "Multiple compression algorithms",
"Batch processing",
"No file size limits",
- "Condense algorithm for smart compression",
- "Photon algorithm for image-heavy PDFs"
+ "Hyper Compress engine built in",
+ "Compress to a target file size"
]
}
@@ -646,17 +451,11 @@
"@type": "HowToStep",
"position": 2,
"name": "Choose Compression Level",
- "text": "Select your desired compression level: Light, Balanced, Aggressive, or Extreme"
+ "text": "Select your desired compression level: Low, Medium, High, or Lossless"
},
{
"@type": "HowToStep",
"position": 3,
- "name": "Select Algorithm",
- "text": "Choose Condense (recommended) or Photon algorithm based on your PDF type"
- },
- {
- "@type": "HowToStep",
- "position": 4,
"name": "Compress and Download",
"text": "Click 'Compress PDF' and download your optimized file"
}
diff --git a/src/pages/compress-pdf.html b/src/pages/compress-pdf.html
index 56872cd5..4b8d2e71 100644
--- a/src/pages/compress-pdf.html
+++ b/src/pages/compress-pdf.html
@@ -175,189 +175,7 @@
-
-
-
-
-
-
- Condense uses advanced compression: removes
- dead-weight, optimizes images, subsets fonts. Best for most
- PDFs.
-
-
- Photon converts pages to images. Use for
- photo-heavy/scanned PDFs.
- ⚠️ Warning: Text will become non-selectable and links will
- stop working.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reduces file size by removing color information
-
-
-
-
-
-
-
-
-
- Fine-tune compression parameters:
-
-
-
-
-
-
-
1-100%
-
-
-
-
-
DPI
-
-
-
-
-
DPI
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {{> compress-options }}
@@ -430,10 +248,10 @@
- Choose Algorithm
+ Select Compression Level
- Select Condense (recommended) or Photon (for photo-heavy PDFs)
+ Pick Low, Medium, High, or Lossless compression
@@ -445,10 +263,11 @@
- Select Compression Level
+ Customize & Compress
- Pick Light, Balanced, Aggressive, or Extreme compression
+ Optionally set a target size or adjust custom settings such as
+ grayscale, then compress
@@ -458,21 +277,6 @@
>
4
-
-
- Customize & Compress
-
-
- Optionally enable grayscale or custom settings, then compress
-
-
-
-
-
- 5
-
Download
@@ -583,9 +387,10 @@
Most of a PDF's size comes from images, so a file with few images,
or images that are already small, won't shrink much, and the result
- message tells you when the size couldn't be reduced. Try a stronger
- level such as Aggressive, turn on Convert to Grayscale, or switch to
- Photon if the file is a scan.
+ message tells you when the size couldn't be reduced. Try the High
+ level, set a target file size, or turn on Convert to grayscale under
+ Custom Settings. For an image-only scan, turn on Rasterize pages
+ there too.
@@ -594,10 +399,11 @@
- With Condense, Light keeps images at 90% JPEG quality and 150 DPI,
- Balanced uses 75% and 96 DPI, Aggressive uses 50% and 72 DPI, and
- Extreme uses 30% and 60 DPI. Balanced is the default and reads fine
- on screen; Light is the safer choice for anything that will be
+ Low re-encodes images at JPEG quality 80 and downsamples anything
+ above 200 DPI, Medium uses quality 50 and 150 DPI, and High uses
+ quality 20 and 72 DPI. Lossless leaves images untouched and only
+ optimizes fonts and file structure. Medium is the default and reads
+ fine on screen; Low is the safer choice for anything that will be
printed.
@@ -607,10 +413,12 @@
- With Condense, yes: it recompresses images, subsets fonts, and
- cleans up the file structure without touching the text layer, so
- selection, search, and links keep working. Photon renders every page
- to a JPEG, which permanently removes selectable text and hyperlinks.
+ Yes. Compression recompresses images, subsets fonts, and cleans up
+ the file structure without touching the text layer, so selection,
+ search, and links keep working. Rasterize pages only applies to
+ image-only scans and is skipped whenever a document has selectable
+ text, unless you turn on Override content protection under Custom
+ Settings.
@@ -619,10 +427,16 @@
- It depends on the level. Light keeps the document info fields,
- Balanced, Aggressive, and Extreme strip them, and the two strongest
- levels also remove XMP metadata. You can override this either way
- with the Remove metadata checkbox under Custom Settings.
+ Compression keeps document details such as the title and author. It
+ drops embedded page thumbnails and private data that editing apps
+ leave behind, and a PDF/A file loses its PDF/A label unless you turn
+ on Preserve PDF/A under Custom Settings. To strip document details
+ as well, use
+ Remove Metadata.
@@ -632,10 +446,9 @@
Scanned PDFs are full-page images, so they respond well to
- compression. Start with Condense at Balanced or Aggressive, which
- downsamples the scans in place, and use Photon only if you need a
- smaller result and can accept losing any OCR text layer the scan may
- have.
+ compression. Medium or High downsamples the scans in place and keeps
+ any OCR text layer. If the scan has no text layer at all, turning on
+ Rasterize pages under Custom Settings can make it smaller still.
@@ -644,11 +457,14 @@
- They override the preset for the Condense algorithm: Output Quality
- (1 to 100) sets JPEG quality, Resize Images To sets the target DPI,
- and Only Process Above leaves images already below that DPI alone.
- Checkboxes let you remove metadata, subset fonts, and drop embedded
- thumbnails.
+ They fine-tune Hyper Compress. You can set image quality and maximum
+ resolution, control how fonts are subset and merged, flatten
+ annotations, form fields, or links, remove thumbnails and other
+ hidden data, rasterize image-only scans, keep PDF/A conformance, and
+ turn on Brotli. Override content protection lets you compress signed
+ PDFs by removing their signatures and rasterize pages that have
+ text. Picking a compression level resets the rest to that level's
+ values.
@@ -657,10 +473,11 @@
- There's no target size box on this page, so step through the levels
- and check the size in the result message until the file fits. For
- common limits there are dedicated pages with settings chosen for
- that ceiling, such as
+ Yes. Enter the size under Target File Size and Hyper Compress
+ searches for the best image quality that still fits. If the file
+ can't get that small, you get the smallest version it can make and a
+ note saying so. For common limits there are pages with the target
+ already filled in, such as
@@ -706,11 +527,10 @@
},
"featureList": [
"Reduce PDF file size",
- "Multiple compression algorithms",
"Batch processing",
"No file size limits",
- "Condense algorithm for smart compression",
- "Photon algorithm for image-heavy PDFs"
+ "Hyper Compress engine built in",
+ "Compress to a target file size"
]
}
@@ -732,17 +552,11 @@
"@type": "HowToStep",
"position": 2,
"name": "Choose Compression Level",
- "text": "Select your desired compression level: Light, Balanced, Aggressive, or Extreme"
+ "text": "Select your desired compression level: Low, Medium, High, or Lossless"
},
{
"@type": "HowToStep",
"position": 3,
- "name": "Select Algorithm",
- "text": "Choose Condense (recommended) or Photon algorithm based on your PDF type"
- },
- {
- "@type": "HowToStep",
- "position": 4,
"name": "Compress and Download",
"text": "Click 'Compress PDF' and download your optimized file"
}
diff --git a/src/partials/compress-options.html b/src/partials/compress-options.html
new file mode 100644
index 00000000..2c2722e8
--- /dev/null
+++ b/src/partials/compress-options.html
@@ -0,0 +1,110 @@
+
+
+
+
+
+ Powered by Hyper Compress: it recompresses images, subsets and merges
+ fonts, and strips dead weight while text, links, and forms keep working.
+ For image-only scans, turn on Rasterize pages under Custom Settings.
+
+
+
+
+
+
+
+
+
+
+ Hyper Compress finds the best quality that fits. Leave it empty to use the
+ level as is.
+
+
+
+
+
+
+
+
+ Fine-tune Hyper Compress. Picking a compression level resets these,
+ except Override content protection, Convert to grayscale, Preserve
+ PDF/A, and Brotli.
+