mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 03:50:29 +01:00
feat: replace compression with Hyper Compress, fix PDF to Word color pass and stale engine URLs
This commit is contained in:
+41
-37
@@ -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
|
||||
|
||||
Generated
+12
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+6
-9
@@ -346,12 +346,10 @@
|
||||
class="text-indigo-300 hover:text-indigo-400"
|
||||
>Compress PDF</a
|
||||
>
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -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
|
||||
<a href="/ocr-pdf" class="text-indigo-300 hover:text-indigo-400"
|
||||
>OCR PDF</a
|
||||
>.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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エディター",
|
||||
|
||||
@@ -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 편집기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 編輯器",
|
||||
|
||||
@@ -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 编辑器",
|
||||
|
||||
+978
-455
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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).",
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
+169
-142
@@ -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<string, CompressLevel> = {
|
||||
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<keyof HyperCompressOptions> =
|
||||
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<Uint8Array> {
|
||||
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<Blob>((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<HyperCompressResponse>) => {
|
||||
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<HyperCompressResponse> {
|
||||
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<HyperWasmResult> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Uint8Array> {
|
||||
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<Blob> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
return !hasEncryptKey(input) || verifyPassword(input, password);
|
||||
}
|
||||
|
||||
self.onmessage = async (e: MessageEvent<HyperCompressRequest>) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -50,7 +50,6 @@ export async function loadPyMuPDF(): Promise<PyMuPDFInstance> {
|
||||
}
|
||||
|
||||
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<PyMuPDFInstance> {
|
||||
|
||||
cachedPyMuPDF = new module.PyMuPDF({
|
||||
assetPath: `${normalizedPymupdf}assets/`,
|
||||
ghostscriptUrl: gsUrl,
|
||||
});
|
||||
|
||||
await cachedPyMuPDF.load();
|
||||
|
||||
@@ -56,6 +56,16 @@ function collectBuiltinTrustedHosts(): Set<string> {
|
||||
|
||||
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<WasmPackage, boolean> = 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',
|
||||
],
|
||||
|
||||
@@ -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<Record<string, SocketData>> {
|
||||
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 {
|
||||
|
||||
@@ -86,23 +86,14 @@ const CONTROL_SPECS: Record<string, Record<string, WorkflowControlSpec>> = {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -182,189 +182,7 @@
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="compress-options" class="hidden mt-6 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="compression-algorithm"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Algorithm</label
|
||||
>
|
||||
<select
|
||||
id="compression-algorithm"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="condense">Condense (Recommended)</option>
|
||||
<option value="photon">Photon (For Photo-Heavy PDFs)</option>
|
||||
</select>
|
||||
<div id="algorithm-info" class="mt-2 text-xs text-gray-400">
|
||||
<p id="condense-info">
|
||||
<strong>Condense</strong> uses advanced compression: removes
|
||||
dead-weight, optimizes images, subsets fonts. Best for most
|
||||
PDFs.
|
||||
</p>
|
||||
<p id="photon-info" class="hidden">
|
||||
<strong>Photon</strong> converts pages to images. Use for
|
||||
photo-heavy/scanned PDFs.
|
||||
<span class="text-yellow-500"
|
||||
>⚠️ Warning: Text will become non-selectable and links will
|
||||
stop working.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="compression-level"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Level</label
|
||||
>
|
||||
<select
|
||||
id="compression-level"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="light">Light (Preserve Quality)</option>
|
||||
<option value="balanced" selected>Balanced (Recommended)</option>
|
||||
<option value="aggressive">Aggressive (Smaller Files)</option>
|
||||
<option value="extreme">Extreme (Maximum Compression)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grayscale toggle - always visible -->
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg border border-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="convert-to-grayscale"
|
||||
class="w-5 h-5 rounded cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="convert-to-grayscale"
|
||||
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||
>Convert to Grayscale</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Reduces file size by removing color information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="toggle-custom-settings"
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-indigo-300 hover:text-indigo-400 text-sm font-medium"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
<span>Custom Settings</span>
|
||||
<i
|
||||
data-lucide="chevron-down"
|
||||
id="custom-settings-chevron"
|
||||
class="w-4 h-4"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="custom-settings-panel"
|
||||
class="hidden mt-4 p-4 bg-gray-900 rounded-lg border border-gray-700 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-gray-400 mb-2">
|
||||
Fine-tune compression parameters:
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="image-quality"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Output Quality</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="image-quality"
|
||||
value="75"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">1-100%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-target"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Resize Images To</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-target"
|
||||
value="96"
|
||||
min="36"
|
||||
max="300"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-threshold"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Only Process Above</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-threshold"
|
||||
value="150"
|
||||
min="72"
|
||||
max="600"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-metadata"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Remove metadata</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subset-fonts"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Subset fonts (remove unused glyphs)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-thumbnails"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Remove embedded thumbnails</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Compress PDF
|
||||
</button>
|
||||
</div>
|
||||
{{> compress-options targetSize="10" targetUnit="mb" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -410,9 +228,10 @@
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<p id="direct-answer" class="text-gray-300 text-lg leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -434,10 +253,10 @@
|
||||
</h2>
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -482,10 +301,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Choose Algorithm
|
||||
Select Compression Level
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Select Condense (recommended) or Photon (for photo-heavy PDFs)
|
||||
Pick Low, Medium, High, or Lossless compression
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,10 +316,11 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Select Compression Level
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Pick Light, Balanced, Aggressive, or Extreme compression
|
||||
Optionally set a target size or adjust custom settings such as
|
||||
grayscale, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,21 +330,6 @@
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Optionally enable grayscale or custom settings, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||
<p class="text-gray-400">
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -179,189 +179,7 @@
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="compress-options" class="hidden mt-6 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="compression-algorithm"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Algorithm</label
|
||||
>
|
||||
<select
|
||||
id="compression-algorithm"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="condense">Condense (Recommended)</option>
|
||||
<option value="photon">Photon (For Photo-Heavy PDFs)</option>
|
||||
</select>
|
||||
<div id="algorithm-info" class="mt-2 text-xs text-gray-400">
|
||||
<p id="condense-info">
|
||||
<strong>Condense</strong> uses advanced compression: removes
|
||||
dead-weight, optimizes images, subsets fonts. Best for most
|
||||
PDFs.
|
||||
</p>
|
||||
<p id="photon-info" class="hidden">
|
||||
<strong>Photon</strong> converts pages to images. Use for
|
||||
photo-heavy/scanned PDFs.
|
||||
<span class="text-yellow-500"
|
||||
>⚠️ Warning: Text will become non-selectable and links will
|
||||
stop working.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="compression-level"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Level</label
|
||||
>
|
||||
<select
|
||||
id="compression-level"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="light">Light (Preserve Quality)</option>
|
||||
<option value="balanced" selected>Balanced (Recommended)</option>
|
||||
<option value="aggressive">Aggressive (Smaller Files)</option>
|
||||
<option value="extreme">Extreme (Maximum Compression)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grayscale toggle - always visible -->
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg border border-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="convert-to-grayscale"
|
||||
class="w-5 h-5 rounded cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="convert-to-grayscale"
|
||||
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||
>Convert to Grayscale</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Reduces file size by removing color information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="toggle-custom-settings"
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-indigo-300 hover:text-indigo-400 text-sm font-medium"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
<span>Custom Settings</span>
|
||||
<i
|
||||
data-lucide="chevron-down"
|
||||
id="custom-settings-chevron"
|
||||
class="w-4 h-4"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="custom-settings-panel"
|
||||
class="hidden mt-4 p-4 bg-gray-900 rounded-lg border border-gray-700 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-gray-400 mb-2">
|
||||
Fine-tune compression parameters:
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="image-quality"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Output Quality</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="image-quality"
|
||||
value="75"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">1-100%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-target"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Resize Images To</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-target"
|
||||
value="96"
|
||||
min="36"
|
||||
max="300"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-threshold"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Only Process Above</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-threshold"
|
||||
value="150"
|
||||
min="72"
|
||||
max="600"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-metadata"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Remove metadata</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subset-fonts"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Subset fonts (remove unused glyphs)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-thumbnails"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Remove embedded thumbnails</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Compress PDF
|
||||
</button>
|
||||
</div>
|
||||
{{> compress-options targetSize="100" targetUnit="kb" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -406,11 +224,11 @@
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<p id="direct-answer" class="text-gray-300 text-lg leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -481,10 +300,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Choose Algorithm
|
||||
Select Compression Level
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Select Condense (recommended) or Photon (for photo-heavy PDFs)
|
||||
Pick Low, Medium, High, or Lossless compression
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,10 +315,11 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Select Compression Level
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Pick Light, Balanced, Aggressive, or Extreme compression
|
||||
Optionally set a target size or adjust custom settings such as
|
||||
grayscale, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -509,21 +329,6 @@
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Optionally enable grayscale or custom settings, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||
<p class="text-gray-400">
|
||||
@@ -585,9 +390,9 @@
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -598,10 +403,11 @@
|
||||
></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -612,9 +418,10 @@
|
||||
></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -171,189 +171,7 @@
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="compress-options" class="hidden mt-6 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="compression-algorithm"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Algorithm</label
|
||||
>
|
||||
<select
|
||||
id="compression-algorithm"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="condense">Condense (Recommended)</option>
|
||||
<option value="photon">Photon (For Photo-Heavy PDFs)</option>
|
||||
</select>
|
||||
<div id="algorithm-info" class="mt-2 text-xs text-gray-400">
|
||||
<p id="condense-info">
|
||||
<strong>Condense</strong> uses advanced compression: removes
|
||||
dead-weight, optimizes images, subsets fonts. Best for most
|
||||
PDFs.
|
||||
</p>
|
||||
<p id="photon-info" class="hidden">
|
||||
<strong>Photon</strong> converts pages to images. Use for
|
||||
photo-heavy/scanned PDFs.
|
||||
<span class="text-yellow-500"
|
||||
>⚠️ Warning: Text will become non-selectable and links will
|
||||
stop working.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="compression-level"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Level</label
|
||||
>
|
||||
<select
|
||||
id="compression-level"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="light">Light (Preserve Quality)</option>
|
||||
<option value="balanced" selected>Balanced (Recommended)</option>
|
||||
<option value="aggressive">Aggressive (Smaller Files)</option>
|
||||
<option value="extreme">Extreme (Maximum Compression)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grayscale toggle - always visible -->
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg border border-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="convert-to-grayscale"
|
||||
class="w-5 h-5 rounded cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="convert-to-grayscale"
|
||||
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||
>Convert to Grayscale</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Reduces file size by removing color information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="toggle-custom-settings"
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-indigo-300 hover:text-indigo-400 text-sm font-medium"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
<span>Custom Settings</span>
|
||||
<i
|
||||
data-lucide="chevron-down"
|
||||
id="custom-settings-chevron"
|
||||
class="w-4 h-4"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="custom-settings-panel"
|
||||
class="hidden mt-4 p-4 bg-gray-900 rounded-lg border border-gray-700 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-gray-400 mb-2">
|
||||
Fine-tune compression parameters:
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="image-quality"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Output Quality</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="image-quality"
|
||||
value="75"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">1-100%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-target"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Resize Images To</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-target"
|
||||
value="96"
|
||||
min="36"
|
||||
max="300"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-threshold"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Only Process Above</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-threshold"
|
||||
value="150"
|
||||
min="72"
|
||||
max="600"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-metadata"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Remove metadata</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subset-fonts"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Subset fonts (remove unused glyphs)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-thumbnails"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Remove embedded thumbnails</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Compress PDF
|
||||
</button>
|
||||
</div>
|
||||
{{> compress-options targetSize="1" targetUnit="mb" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -398,10 +216,11 @@
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<p id="direct-answer" class="text-gray-300 text-lg leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -411,7 +230,7 @@
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
@@ -419,10 +238,11 @@
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
<h2 class="text-2xl font-bold text-white mb-3">Very large files</h2>
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -456,10 +276,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Choose Algorithm
|
||||
Select Compression Level
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Select Condense (recommended) or Photon (for photo-heavy PDFs)
|
||||
Pick Low, Medium, High, or Lossless compression
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,10 +291,11 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Select Compression Level
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Pick Light, Balanced, Aggressive, or Extreme compression
|
||||
Optionally set a target size or adjust custom settings such as
|
||||
grayscale, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,21 +305,6 @@
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Optionally enable grayscale or custom settings, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||
<p class="text-gray-400">
|
||||
@@ -585,9 +391,11 @@
|
||||
></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -179,189 +179,7 @@
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="compress-options" class="hidden mt-6 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="compression-algorithm"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Algorithm</label
|
||||
>
|
||||
<select
|
||||
id="compression-algorithm"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="condense">Condense (Recommended)</option>
|
||||
<option value="photon">Photon (For Photo-Heavy PDFs)</option>
|
||||
</select>
|
||||
<div id="algorithm-info" class="mt-2 text-xs text-gray-400">
|
||||
<p id="condense-info">
|
||||
<strong>Condense</strong> uses advanced compression: removes
|
||||
dead-weight, optimizes images, subsets fonts. Best for most
|
||||
PDFs.
|
||||
</p>
|
||||
<p id="photon-info" class="hidden">
|
||||
<strong>Photon</strong> converts pages to images. Use for
|
||||
photo-heavy/scanned PDFs.
|
||||
<span class="text-yellow-500"
|
||||
>⚠️ Warning: Text will become non-selectable and links will
|
||||
stop working.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="compression-level"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Level</label
|
||||
>
|
||||
<select
|
||||
id="compression-level"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="light">Light (Preserve Quality)</option>
|
||||
<option value="balanced" selected>Balanced (Recommended)</option>
|
||||
<option value="aggressive">Aggressive (Smaller Files)</option>
|
||||
<option value="extreme">Extreme (Maximum Compression)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grayscale toggle - always visible -->
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg border border-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="convert-to-grayscale"
|
||||
class="w-5 h-5 rounded cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="convert-to-grayscale"
|
||||
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||
>Convert to Grayscale</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Reduces file size by removing color information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="toggle-custom-settings"
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-indigo-300 hover:text-indigo-400 text-sm font-medium"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
<span>Custom Settings</span>
|
||||
<i
|
||||
data-lucide="chevron-down"
|
||||
id="custom-settings-chevron"
|
||||
class="w-4 h-4"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="custom-settings-panel"
|
||||
class="hidden mt-4 p-4 bg-gray-900 rounded-lg border border-gray-700 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-gray-400 mb-2">
|
||||
Fine-tune compression parameters:
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="image-quality"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Output Quality</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="image-quality"
|
||||
value="75"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">1-100%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-target"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Resize Images To</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-target"
|
||||
value="96"
|
||||
min="36"
|
||||
max="300"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-threshold"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Only Process Above</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-threshold"
|
||||
value="150"
|
||||
min="72"
|
||||
max="600"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-metadata"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Remove metadata</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subset-fonts"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Subset fonts (remove unused glyphs)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-thumbnails"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Remove embedded thumbnails</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Compress PDF
|
||||
</button>
|
||||
</div>
|
||||
{{> compress-options targetSize="200" targetUnit="kb" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -406,9 +224,10 @@
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<p id="direct-answer" class="text-gray-300 text-lg leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -418,8 +237,8 @@
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -427,10 +246,11 @@
|
||||
Getting a stubborn file under 200KB
|
||||
</h2>
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -464,10 +284,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Choose Algorithm
|
||||
Select Compression Level
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Select Condense (recommended) or Photon (for photo-heavy PDFs)
|
||||
Pick Low, Medium, High, or Lossless compression
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,10 +299,11 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Select Compression Level
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Pick Light, Balanced, Aggressive, or Extreme compression
|
||||
Optionally set a target size or adjust custom settings such as
|
||||
grayscale, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -492,21 +313,6 @@
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Optionally enable grayscale or custom settings, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||
<p class="text-gray-400">
|
||||
@@ -567,9 +373,8 @@
|
||||
></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -580,9 +385,9 @@
|
||||
></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -171,189 +171,7 @@
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="compress-options" class="hidden mt-6 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="compression-algorithm"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Algorithm</label
|
||||
>
|
||||
<select
|
||||
id="compression-algorithm"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="condense">Condense (Recommended)</option>
|
||||
<option value="photon">Photon (For Photo-Heavy PDFs)</option>
|
||||
</select>
|
||||
<div id="algorithm-info" class="mt-2 text-xs text-gray-400">
|
||||
<p id="condense-info">
|
||||
<strong>Condense</strong> uses advanced compression: removes
|
||||
dead-weight, optimizes images, subsets fonts. Best for most
|
||||
PDFs.
|
||||
</p>
|
||||
<p id="photon-info" class="hidden">
|
||||
<strong>Photon</strong> converts pages to images. Use for
|
||||
photo-heavy/scanned PDFs.
|
||||
<span class="text-yellow-500"
|
||||
>⚠️ Warning: Text will become non-selectable and links will
|
||||
stop working.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="compression-level"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Level</label
|
||||
>
|
||||
<select
|
||||
id="compression-level"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="light">Light (Preserve Quality)</option>
|
||||
<option value="balanced" selected>Balanced (Recommended)</option>
|
||||
<option value="aggressive">Aggressive (Smaller Files)</option>
|
||||
<option value="extreme">Extreme (Maximum Compression)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grayscale toggle - always visible -->
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg border border-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="convert-to-grayscale"
|
||||
class="w-5 h-5 rounded cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="convert-to-grayscale"
|
||||
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||
>Convert to Grayscale</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Reduces file size by removing color information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="toggle-custom-settings"
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-indigo-300 hover:text-indigo-400 text-sm font-medium"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
<span>Custom Settings</span>
|
||||
<i
|
||||
data-lucide="chevron-down"
|
||||
id="custom-settings-chevron"
|
||||
class="w-4 h-4"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="custom-settings-panel"
|
||||
class="hidden mt-4 p-4 bg-gray-900 rounded-lg border border-gray-700 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-gray-400 mb-2">
|
||||
Fine-tune compression parameters:
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="image-quality"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Output Quality</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="image-quality"
|
||||
value="75"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">1-100%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-target"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Resize Images To</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-target"
|
||||
value="96"
|
||||
min="36"
|
||||
max="300"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-threshold"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Only Process Above</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-threshold"
|
||||
value="150"
|
||||
min="72"
|
||||
max="600"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-metadata"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Remove metadata</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subset-fonts"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Subset fonts (remove unused glyphs)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-thumbnails"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Remove embedded thumbnails</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Compress PDF
|
||||
</button>
|
||||
</div>
|
||||
{{> compress-options targetSize="2" targetUnit="mb" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -398,10 +216,11 @@
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<p id="direct-answer" class="text-gray-300 text-lg leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -409,9 +228,9 @@
|
||||
Quality-first compression
|
||||
</h2>
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
@@ -421,8 +240,9 @@
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -456,10 +276,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Choose Algorithm
|
||||
Select Compression Level
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Select Condense (recommended) or Photon (for photo-heavy PDFs)
|
||||
Pick Low, Medium, High, or Lossless compression
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,10 +291,11 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Select Compression Level
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Pick Light, Balanced, Aggressive, or Extreme compression
|
||||
Optionally set a target size or adjust custom settings such as
|
||||
grayscale, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,21 +305,6 @@
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Optionally enable grayscale or custom settings, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||
<p class="text-gray-400">
|
||||
@@ -559,8 +365,10 @@
|
||||
></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -585,8 +393,8 @@
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -179,189 +179,7 @@
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="compress-options" class="hidden mt-6 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="compression-algorithm"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Algorithm</label
|
||||
>
|
||||
<select
|
||||
id="compression-algorithm"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="condense">Condense (Recommended)</option>
|
||||
<option value="photon">Photon (For Photo-Heavy PDFs)</option>
|
||||
</select>
|
||||
<div id="algorithm-info" class="mt-2 text-xs text-gray-400">
|
||||
<p id="condense-info">
|
||||
<strong>Condense</strong> uses advanced compression: removes
|
||||
dead-weight, optimizes images, subsets fonts. Best for most
|
||||
PDFs.
|
||||
</p>
|
||||
<p id="photon-info" class="hidden">
|
||||
<strong>Photon</strong> converts pages to images. Use for
|
||||
photo-heavy/scanned PDFs.
|
||||
<span class="text-yellow-500"
|
||||
>⚠️ Warning: Text will become non-selectable and links will
|
||||
stop working.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="compression-level"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Level</label
|
||||
>
|
||||
<select
|
||||
id="compression-level"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="light">Light (Preserve Quality)</option>
|
||||
<option value="balanced" selected>Balanced (Recommended)</option>
|
||||
<option value="aggressive">Aggressive (Smaller Files)</option>
|
||||
<option value="extreme">Extreme (Maximum Compression)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grayscale toggle - always visible -->
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg border border-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="convert-to-grayscale"
|
||||
class="w-5 h-5 rounded cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="convert-to-grayscale"
|
||||
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||
>Convert to Grayscale</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Reduces file size by removing color information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="toggle-custom-settings"
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-indigo-300 hover:text-indigo-400 text-sm font-medium"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
<span>Custom Settings</span>
|
||||
<i
|
||||
data-lucide="chevron-down"
|
||||
id="custom-settings-chevron"
|
||||
class="w-4 h-4"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="custom-settings-panel"
|
||||
class="hidden mt-4 p-4 bg-gray-900 rounded-lg border border-gray-700 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-gray-400 mb-2">
|
||||
Fine-tune compression parameters:
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="image-quality"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Output Quality</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="image-quality"
|
||||
value="75"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">1-100%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-target"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Resize Images To</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-target"
|
||||
value="96"
|
||||
min="36"
|
||||
max="300"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-threshold"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Only Process Above</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-threshold"
|
||||
value="150"
|
||||
min="72"
|
||||
max="600"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-metadata"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Remove metadata</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subset-fonts"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Subset fonts (remove unused glyphs)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-thumbnails"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Remove embedded thumbnails</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Compress PDF
|
||||
</button>
|
||||
</div>
|
||||
{{> compress-options targetSize="500" targetUnit="kb" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -406,8 +224,8 @@
|
||||
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<p id="direct-answer" class="text-gray-300 text-lg leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
@@ -415,10 +233,10 @@
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
<h2 class="text-2xl font-bold text-white mb-3">The sweet spot target</h2>
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
<section class="max-w-4xl mx-auto px-4 pt-8">
|
||||
@@ -426,10 +244,11 @@
|
||||
If you are just over the line
|
||||
</h2>
|
||||
<p class="text-gray-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -463,10 +282,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Choose Algorithm
|
||||
Select Compression Level
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Select Condense (recommended) or Photon (for photo-heavy PDFs)
|
||||
Pick Low, Medium, High, or Lossless compression
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,10 +297,11 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Select Compression Level
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Pick Light, Balanced, Aggressive, or Extreme compression
|
||||
Optionally set a target size or adjust custom settings such as
|
||||
grayscale, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -491,21 +311,6 @@
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Optionally enable grayscale or custom settings, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||
<p class="text-gray-400">
|
||||
@@ -566,9 +371,10 @@
|
||||
></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+59
-245
@@ -175,189 +175,7 @@
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="compress-options" class="hidden mt-6 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="compression-algorithm"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Algorithm</label
|
||||
>
|
||||
<select
|
||||
id="compression-algorithm"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="condense">Condense (Recommended)</option>
|
||||
<option value="photon">Photon (For Photo-Heavy PDFs)</option>
|
||||
</select>
|
||||
<div id="algorithm-info" class="mt-2 text-xs text-gray-400">
|
||||
<p id="condense-info">
|
||||
<strong>Condense</strong> uses advanced compression: removes
|
||||
dead-weight, optimizes images, subsets fonts. Best for most
|
||||
PDFs.
|
||||
</p>
|
||||
<p id="photon-info" class="hidden">
|
||||
<strong>Photon</strong> converts pages to images. Use for
|
||||
photo-heavy/scanned PDFs.
|
||||
<span class="text-yellow-500"
|
||||
>⚠️ Warning: Text will become non-selectable and links will
|
||||
stop working.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="compression-level"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Level</label
|
||||
>
|
||||
<select
|
||||
id="compression-level"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="light">Light (Preserve Quality)</option>
|
||||
<option value="balanced" selected>Balanced (Recommended)</option>
|
||||
<option value="aggressive">Aggressive (Smaller Files)</option>
|
||||
<option value="extreme">Extreme (Maximum Compression)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Grayscale toggle - always visible -->
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-gray-800 rounded-lg border border-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="convert-to-grayscale"
|
||||
class="w-5 h-5 rounded cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="convert-to-grayscale"
|
||||
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||
>Convert to Grayscale</label
|
||||
>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Reduces file size by removing color information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="toggle-custom-settings"
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-indigo-300 hover:text-indigo-400 text-sm font-medium"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
<span>Custom Settings</span>
|
||||
<i
|
||||
data-lucide="chevron-down"
|
||||
id="custom-settings-chevron"
|
||||
class="w-4 h-4"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="custom-settings-panel"
|
||||
class="hidden mt-4 p-4 bg-gray-900 rounded-lg border border-gray-700 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-gray-400 mb-2">
|
||||
Fine-tune compression parameters:
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="image-quality"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Output Quality</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="image-quality"
|
||||
value="75"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">1-100%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-target"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Resize Images To</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-target"
|
||||
value="96"
|
||||
min="36"
|
||||
max="300"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="dpi-threshold"
|
||||
class="block mb-1 text-xs font-medium text-gray-400"
|
||||
>Only Process Above</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="dpi-threshold"
|
||||
value="150"
|
||||
min="72"
|
||||
max="600"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">DPI</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-metadata"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300">Remove metadata</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subset-fonts"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Subset fonts (remove unused glyphs)</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remove-thumbnails"
|
||||
checked
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-300"
|
||||
>Remove embedded thumbnails</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Compress PDF
|
||||
</button>
|
||||
</div>
|
||||
{{> compress-options }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -430,10 +248,10 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Choose Algorithm
|
||||
Select Compression Level
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Select Condense (recommended) or Photon (for photo-heavy PDFs)
|
||||
Pick Low, Medium, High, or Lossless compression
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,10 +263,11 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Select Compression Level
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Pick Light, Balanced, Aggressive, or Extreme compression
|
||||
Optionally set a target size or adjust custom settings such as
|
||||
grayscale, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -458,21 +277,6 @@
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
Customize & Compress
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
Optionally enable grayscale or custom settings, then compress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
|
||||
>
|
||||
5
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
|
||||
<p class="text-gray-400">
|
||||
@@ -583,9 +387,10 @@
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -594,10 +399,11 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
@@ -607,10 +413,12 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -619,10 +427,16 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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
|
||||
<a
|
||||
href="/remove-metadata"
|
||||
class="text-indigo-300 hover:text-indigo-400"
|
||||
>Remove Metadata</a
|
||||
>.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -632,10 +446,9 @@
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -644,11 +457,14 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
<details class="faq-d">
|
||||
@@ -657,10 +473,11 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
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
|
||||
<a
|
||||
href="/compress-pdf-to-1mb"
|
||||
class="text-indigo-300 hover:text-indigo-400"
|
||||
@@ -674,11 +491,15 @@
|
||||
<i data-lucide="plus" class="faq-d-icon"></i>
|
||||
</summary>
|
||||
<p class="faq-d-a">
|
||||
No. Condense runs a PyMuPDF engine compiled to WebAssembly and
|
||||
Photon uses the browser's own page renderer, so everything happens
|
||||
on your device. You can compress one file or a whole batch, which
|
||||
comes back as compressed-pdfs.zip, and none of them leave your
|
||||
computer.
|
||||
No. Compression runs
|
||||
<a
|
||||
href="/hyper-compress"
|
||||
class="text-indigo-300 hover:text-indigo-400"
|
||||
>Hyper Compress</a
|
||||
>, BentoPDF's own engine, compiled to WebAssembly and served with
|
||||
the app, so everything happens on your device. You can compress one
|
||||
file or a whole batch, which comes back as compressed-pdfs.zip, and
|
||||
none of them leave your computer.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<div id="compress-options" class="hidden mt-6 space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="compression-level"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Compression Level</label
|
||||
>
|
||||
<select
|
||||
id="compression-level"
|
||||
autocomplete="off"
|
||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="low">Low (Best Quality)</option>
|
||||
<option value="medium" selected>Medium (Recommended)</option>
|
||||
<option value="high">High (Smallest Files)</option>
|
||||
<option value="lossless">Lossless (No Quality Loss)</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="target-size"
|
||||
class="block mb-2 text-sm font-medium text-gray-300"
|
||||
>Target File Size (Optional)</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
id="target-size"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value="{{targetSize}}"
|
||||
placeholder="No target"
|
||||
autocomplete="off"
|
||||
class="flex-1 min-w-0 bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
<select
|
||||
id="target-size-unit"
|
||||
data-default-unit="{{targetUnit}}"
|
||||
aria-label="Target file size unit"
|
||||
autocomplete="off"
|
||||
class="bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="kb">KB</option>
|
||||
<option value="mb" selected>MB</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Hyper Compress finds the best quality that fits. Leave it empty to use the
|
||||
level as is.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
id="toggle-custom-settings"
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-indigo-300 hover:text-indigo-400 text-sm font-medium"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i>
|
||||
<span>Custom Settings</span>
|
||||
<i
|
||||
data-lucide="chevron-down"
|
||||
id="custom-settings-chevron"
|
||||
class="w-4 h-4"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="custom-settings-panel"
|
||||
class="hidden mt-4 p-4 bg-gray-900 rounded-lg border border-gray-700 space-y-3"
|
||||
>
|
||||
<p class="text-sm text-gray-400">
|
||||
Fine-tune Hyper Compress. Picking a compression level resets these,
|
||||
except Override content protection, Convert to grayscale, Preserve
|
||||
PDF/A, and Brotli.
|
||||
</p>
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="override-protection"
|
||||
autocomplete="off"
|
||||
class="w-4 h-4 mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm text-gray-300"
|
||||
>Override content protection</span
|
||||
>
|
||||
<span class="block text-xs text-gray-500 mt-0.5"
|
||||
>By default, Hyper Compress preserves content: it leaves digitally
|
||||
signed PDFs untouched and won't rasterize pages that have text. Turn
|
||||
this on to bypass both. Signed PDFs lose their signatures, and
|
||||
Rasterize pages also turns text into images.</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<div id="hyper-options" class="space-y-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Compress PDF
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import type { ClassicPreset } from 'rete';
|
||||
import type { HyperWasmResult } from 'hyper-compress-wasm';
|
||||
import type { PDFData } from '../js/workflow/types';
|
||||
|
||||
const { compressWithHyper } = vi.hoisted(() => ({
|
||||
compressWithHyper: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/compress.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../js/utils/compress.js')>()),
|
||||
compressWithHyper,
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/load-pdf-document.js', async () => {
|
||||
const { PDFDocument: Doc } = await import('pdf-lib');
|
||||
return { loadPdfDocument: (bytes: Uint8Array) => Doc.load(bytes) };
|
||||
});
|
||||
|
||||
const { CompressNode } = await import('../js/workflow/nodes/compress-node');
|
||||
|
||||
async function pdfInput(filename: string): Promise<PDFData> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage();
|
||||
return { type: 'pdf', document: doc, bytes: await doc.save(), filename };
|
||||
}
|
||||
|
||||
function setText(
|
||||
node: InstanceType<typeof CompressNode>,
|
||||
key: string,
|
||||
value: string
|
||||
) {
|
||||
(node.controls[key] as ClassicPreset.InputControl<'text'>).setValue(value);
|
||||
}
|
||||
|
||||
function setNumber(
|
||||
node: InstanceType<typeof CompressNode>,
|
||||
key: string,
|
||||
value: number
|
||||
) {
|
||||
(node.controls[key] as ClassicPreset.InputControl<'number'>).setValue(value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
compressWithHyper.mockReset();
|
||||
compressWithHyper.mockImplementation(
|
||||
async (pdf: ArrayBuffer): Promise<HyperWasmResult> => ({
|
||||
data: new Uint8Array(pdf),
|
||||
originalSize: pdf.byteLength,
|
||||
compressedSize: pdf.byteLength,
|
||||
signed: false,
|
||||
signaturesRemoved: false,
|
||||
pdfa: null,
|
||||
pdfaOutcome: 'none',
|
||||
metTarget: null,
|
||||
warnings: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('CompressNode', () => {
|
||||
it('compresses with the chosen level, grayscale and target size', async () => {
|
||||
const node = new CompressNode();
|
||||
setText(node, 'compressionLevel', 'high');
|
||||
setText(node, 'convertToGrayscale', 'true');
|
||||
setNumber(node, 'targetKilobytes', 500);
|
||||
|
||||
const output = (await node.data({ pdf: [await pdfInput('report.pdf')] }))
|
||||
.pdf as PDFData;
|
||||
|
||||
expect(compressWithHyper).toHaveBeenCalledWith(expect.any(ArrayBuffer), {
|
||||
preset: 'high',
|
||||
options: { grayscale: true },
|
||||
targetSizeBytes: 500 * 1024,
|
||||
});
|
||||
expect(output.filename).toBe('report_compressed.pdf');
|
||||
expect(output.document.getPageCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('maps a level from an older workflow and treats 0 KB as no target', async () => {
|
||||
const node = new CompressNode();
|
||||
setText(node, 'compressionLevel', 'extreme');
|
||||
|
||||
await node.data({ pdf: [await pdfInput('scan.PDF')] });
|
||||
|
||||
expect(compressWithHyper).toHaveBeenCalledWith(expect.any(ArrayBuffer), {
|
||||
preset: 'high',
|
||||
options: { grayscale: false },
|
||||
targetSizeBytes: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a copy so the input PDF stays usable', async () => {
|
||||
const input = await pdfInput('a.pdf');
|
||||
const before = Array.from(input.bytes);
|
||||
|
||||
await new CompressNode().data({ pdf: [input] });
|
||||
|
||||
expect(compressWithHyper.mock.calls[0][0]).not.toBe(input.bytes.buffer);
|
||||
expect(Array.from(input.bytes)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import type { HyperWasmResult } from 'hyper-compress-wasm';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
compressWithHyper: vi.fn(),
|
||||
verifyHyperPassword: vi.fn(),
|
||||
showAlert: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/compress.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../js/utils/compress.js')>()),
|
||||
compressWithHyper: mocks.compressWithHyper,
|
||||
verifyHyperPassword: mocks.verifyHyperPassword,
|
||||
}));
|
||||
|
||||
vi.mock('../js/ui.js', () => ({
|
||||
showLoader: vi.fn(),
|
||||
hideLoader: vi.fn(),
|
||||
showAlert: mocks.showAlert,
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/helpers.js', () => ({
|
||||
downloadFile: mocks.downloadFile,
|
||||
formatBytes: (bytes: number) => `${bytes} B`,
|
||||
}));
|
||||
|
||||
const PASSWORD = 'secret';
|
||||
const OPEN_BODY = '%PDF-1.7 open';
|
||||
const LOCKED_BODY = '%PDF-1.7 locked';
|
||||
|
||||
const pageMarkup = `
|
||||
<button id="back-to-tools"></button>
|
||||
<div id="drop-zone">
|
||||
<input type="file" id="file-input" multiple accept="application/pdf" />
|
||||
</div>
|
||||
<div id="file-display-area"></div>
|
||||
<button id="add-more-btn"></button>
|
||||
<button id="clear-files-btn"></button>
|
||||
${readFileSync(resolve('src/partials/compress-options.html'), 'utf8')
|
||||
.replace('{{targetSize}}', '')
|
||||
.replace('{{targetUnit}}', 'mb')}`;
|
||||
|
||||
function result(overrides: Partial<HyperWasmResult> = {}): HyperWasmResult {
|
||||
return {
|
||||
data: new Uint8Array([37, 80, 68, 70]),
|
||||
originalSize: 100,
|
||||
compressedSize: 4,
|
||||
signed: false,
|
||||
signaturesRemoved: false,
|
||||
pdfa: null,
|
||||
pdfaOutcome: 'none',
|
||||
metTarget: null,
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function openPage(): Promise<void> {
|
||||
const parsed = new DOMParser().parseFromString(pageMarkup, 'text/html');
|
||||
document.body.replaceChildren(...Array.from(parsed.body.childNodes));
|
||||
vi.resetModules();
|
||||
const listen = vi.spyOn(document, 'addEventListener');
|
||||
await import('../js/logic/compress-pdf-page');
|
||||
const handler = listen.mock.calls.find(
|
||||
([type]) => type === 'DOMContentLoaded'
|
||||
)?.[1];
|
||||
listen.mockRestore();
|
||||
if (typeof handler !== 'function') throw new Error('page did not start');
|
||||
handler(new Event('DOMContentLoaded'));
|
||||
}
|
||||
|
||||
function pdfFile(name: string, body = OPEN_BODY, type = 'application/pdf') {
|
||||
return new File([body], name, { type });
|
||||
}
|
||||
|
||||
function addFiles(...files: File[]) {
|
||||
const input = document.getElementById('file-input') as HTMLInputElement;
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true });
|
||||
input.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
function cards(): HTMLElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('#file-display-area > div')
|
||||
).filter((card) => card.querySelector('button[aria-label^="Remove "]'));
|
||||
}
|
||||
|
||||
function cardText(name: string): string {
|
||||
const card = cards().find(
|
||||
(candidate) => candidate.querySelector('.truncate')?.textContent === name
|
||||
);
|
||||
return card?.textContent ?? '';
|
||||
}
|
||||
|
||||
function passwordInput(label: string): HTMLInputElement | null {
|
||||
return document.querySelector<HTMLInputElement>(
|
||||
`input[aria-label="${label}"]`
|
||||
);
|
||||
}
|
||||
|
||||
function type(input: HTMLInputElement | null, value: string) {
|
||||
if (!input) throw new Error('no password field');
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
function clickCompress() {
|
||||
(document.getElementById('process-btn') as HTMLButtonElement).click();
|
||||
}
|
||||
|
||||
function alertTitles(): unknown[] {
|
||||
return mocks.showAlert.mock.calls.map(([title]) => title);
|
||||
}
|
||||
|
||||
async function addLockedFile(name: string) {
|
||||
addFiles(pdfFile(name, LOCKED_BODY));
|
||||
await vi.waitFor(() =>
|
||||
expect(passwordInput(`Password for ${name}`)).not.toBeNull()
|
||||
);
|
||||
}
|
||||
|
||||
async function unlock(name: string) {
|
||||
type(passwordInput(`Password for ${name}`), PASSWORD);
|
||||
await vi.waitFor(() => expect(cardText(name)).toContain('Unlocked'));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.verifyHyperPassword.mockImplementation(
|
||||
async (pdf: ArrayBuffer, password: string) =>
|
||||
!new TextDecoder().decode(pdf).includes('locked') || password === PASSWORD
|
||||
);
|
||||
mocks.compressWithHyper.mockResolvedValue(result());
|
||||
await openPage();
|
||||
});
|
||||
|
||||
describe('Compress PDF page', () => {
|
||||
it('asks for a password only on locked PDFs', async () => {
|
||||
addFiles(pdfFile('open.pdf'), pdfFile('locked.pdf', LOCKED_BODY));
|
||||
await vi.waitFor(() =>
|
||||
expect(passwordInput('Password for locked.pdf')).not.toBeNull()
|
||||
);
|
||||
expect(passwordInput('Password for open.pdf')).toBeNull();
|
||||
expect(passwordInput('Password for all locked files')).toBeNull();
|
||||
expect(cardText('locked.pdf')).toContain('Password protected');
|
||||
});
|
||||
|
||||
it('shows a wrong password, then unlocks with the right one', async () => {
|
||||
await addLockedFile('locked.pdf');
|
||||
type(passwordInput('Password for locked.pdf'), 'nope');
|
||||
await vi.waitFor(() =>
|
||||
expect(cardText('locked.pdf')).toContain('Wrong password')
|
||||
);
|
||||
await unlock('locked.pdf');
|
||||
});
|
||||
|
||||
it('will not compress a locked file without its password', async () => {
|
||||
await addLockedFile('locked.pdf');
|
||||
clickCompress();
|
||||
await vi.waitFor(() => expect(alertTitles()).toContain('Password Needed'));
|
||||
expect(mocks.compressWithHyper).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the password and says the copy has none', async () => {
|
||||
await addLockedFile('locked.pdf');
|
||||
await unlock('locked.pdf');
|
||||
clickCompress();
|
||||
await vi.waitFor(() => expect(mocks.downloadFile).toHaveBeenCalled());
|
||||
expect(mocks.compressWithHyper).toHaveBeenCalledWith(
|
||||
expect.any(ArrayBuffer),
|
||||
expect.objectContaining({ password: PASSWORD })
|
||||
);
|
||||
expect(mocks.showAlert.mock.calls.at(-1)?.[1]).toContain(
|
||||
'the compressed copy has no password'
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the password wrong when the engine rejects it', async () => {
|
||||
await addLockedFile('locked.pdf');
|
||||
await unlock('locked.pdf');
|
||||
const { HyperError } = await import('hyper-compress-wasm');
|
||||
mocks.compressWithHyper.mockRejectedValue(
|
||||
new HyperError('decrypt_failed', 'wrong or missing password')
|
||||
);
|
||||
clickCompress();
|
||||
await vi.waitFor(() => expect(alertTitles()).toContain('Password Needed'));
|
||||
expect(cardText('locked.pdf')).toContain('Wrong password');
|
||||
expect(mocks.downloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses files that are not PDFs', async () => {
|
||||
addFiles(pdfFile('notes.pdf', 'just some text'), pdfFile('empty.pdf', ''));
|
||||
await vi.waitFor(() => expect(alertTitles()).toContain('Not a PDF'));
|
||||
expect(mocks.showAlert.mock.calls[0][1]).toContain('notes.pdf, empty.pdf');
|
||||
expect(cards()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps both files in the ZIP when two share a name', async () => {
|
||||
addFiles(pdfFile('report.pdf'), pdfFile('report.pdf'));
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.verifyHyperPassword).toHaveBeenCalledTimes(2)
|
||||
);
|
||||
clickCompress();
|
||||
await vi.waitFor(() => expect(mocks.downloadFile).toHaveBeenCalled());
|
||||
const [zipBlob, zipName] = mocks.downloadFile.mock.calls[0];
|
||||
expect(zipName).toBe('compressed-pdfs.zip');
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = await JSZip.loadAsync(await (zipBlob as Blob).arrayBuffer());
|
||||
expect(Object.keys(zip.files).sort()).toEqual([
|
||||
'report (1).pdf',
|
||||
'report.pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not download a signed PDF', async () => {
|
||||
mocks.compressWithHyper.mockResolvedValue(result({ signed: true }));
|
||||
addFiles(pdfFile('signed.pdf'));
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.verifyHyperPassword).toHaveBeenCalled()
|
||||
);
|
||||
clickCompress();
|
||||
await vi.waitFor(() =>
|
||||
expect(alertTitles()).toContain('Signed PDF Not Changed')
|
||||
);
|
||||
expect(mocks.downloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unlocks every locked file from the shared password field', async () => {
|
||||
addFiles(
|
||||
pdfFile('a.pdf', LOCKED_BODY),
|
||||
pdfFile('b.pdf', LOCKED_BODY),
|
||||
pdfFile('c.pdf')
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(passwordInput('Password for all locked files')).not.toBeNull()
|
||||
);
|
||||
type(passwordInput('Password for all locked files'), PASSWORD);
|
||||
await vi.waitFor(() => {
|
||||
expect(cardText('a.pdf')).toContain('Unlocked');
|
||||
expect(cardText('b.pdf')).toContain('Unlocked');
|
||||
});
|
||||
expect(document.getElementById('file-display-area')?.textContent).toContain(
|
||||
'Unlocked 2 of 2 files.'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts dropped PDFs that have no file type', async () => {
|
||||
const drop = new Event('drop');
|
||||
Object.defineProperty(drop, 'dataTransfer', {
|
||||
value: {
|
||||
files: [
|
||||
pdfFile('scan.pdf', OPEN_BODY, ''),
|
||||
pdfFile('photo.png', 'png', 'image/png'),
|
||||
],
|
||||
},
|
||||
});
|
||||
document.getElementById('drop-zone')?.dispatchEvent(drop);
|
||||
await vi.waitFor(() => expect(cards()).toHaveLength(1));
|
||||
expect(cardText('scan.pdf')).toContain('scan.pdf');
|
||||
});
|
||||
|
||||
it('removes the file whose remove button was clicked', async () => {
|
||||
addFiles(pdfFile('a.pdf'), pdfFile('b.pdf'));
|
||||
await vi.waitFor(() => expect(cards()).toHaveLength(2));
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="Remove b.pdf"]')
|
||||
?.click();
|
||||
expect(cards()).toHaveLength(1);
|
||||
expect(cardText('a.pdf')).toContain('a.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
HYPER_PRESETS,
|
||||
HyperError,
|
||||
normalizeHyperOptions,
|
||||
} from 'hyper-compress-wasm';
|
||||
import type { HyperWasmResult } from 'hyper-compress-wasm';
|
||||
import type { HyperCompressRequest, HyperCompressResponse } from '@/types';
|
||||
import {
|
||||
COMPRESS_LEVELS,
|
||||
applyHyperPreset,
|
||||
hasPdfHeader,
|
||||
matchHyperPreset,
|
||||
resolveCompressLevel,
|
||||
targetSizeToBytes,
|
||||
} from '../js/utils/compress';
|
||||
|
||||
describe('resolveCompressLevel', () => {
|
||||
it('keeps Hyper levels', () => {
|
||||
for (const level of COMPRESS_LEVELS) {
|
||||
expect(resolveCompressLevel(level)).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps levels saved by older workflows', () => {
|
||||
expect(resolveCompressLevel('light')).toBe('low');
|
||||
expect(resolveCompressLevel('balanced')).toBe('medium');
|
||||
expect(resolveCompressLevel('aggressive')).toBe('high');
|
||||
expect(resolveCompressLevel('extreme')).toBe('high');
|
||||
});
|
||||
|
||||
it('falls back to medium for anything else', () => {
|
||||
expect(resolveCompressLevel('custom')).toBe('medium');
|
||||
expect(resolveCompressLevel('')).toBe('medium');
|
||||
expect(resolveCompressLevel('constructor')).toBe('medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchHyperPreset', () => {
|
||||
it('recognises every preset', () => {
|
||||
for (const level of COMPRESS_LEVELS) {
|
||||
expect(
|
||||
matchHyperPreset(normalizeHyperOptions(HYPER_PRESETS[level]))
|
||||
).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores grayscale, PDF/A, Brotli and content protection toggles', () => {
|
||||
const options = normalizeHyperOptions({
|
||||
...HYPER_PRESETS.high,
|
||||
grayscale: true,
|
||||
preserveConformance: true,
|
||||
brotli: true,
|
||||
rasterizeText: true,
|
||||
removeSignatures: true,
|
||||
});
|
||||
expect(matchHyperPreset(options)).toBe('high');
|
||||
});
|
||||
|
||||
it('returns null once a preset option is changed', () => {
|
||||
const options = normalizeHyperOptions({
|
||||
...HYPER_PRESETS.medium,
|
||||
imageQuality: 63,
|
||||
});
|
||||
expect(matchHyperPreset(options)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHyperPreset', () => {
|
||||
it('swaps preset values but keeps the independent toggles', () => {
|
||||
const current = normalizeHyperOptions({
|
||||
...HYPER_PRESETS.medium,
|
||||
imageQuality: 33,
|
||||
grayscale: true,
|
||||
preserveConformance: true,
|
||||
brotli: true,
|
||||
rasterizeText: true,
|
||||
removeSignatures: true,
|
||||
});
|
||||
const next = applyHyperPreset('low', current);
|
||||
expect(next.imageQuality).toBe(HYPER_PRESETS.low.imageQuality);
|
||||
expect(next.maxDpi).toBe(HYPER_PRESETS.low.maxDpi);
|
||||
expect(next.grayscale).toBe(true);
|
||||
expect(next.preserveConformance).toBe(true);
|
||||
expect(next.brotli).toBe(true);
|
||||
expect(next.rasterizeText).toBe(true);
|
||||
expect(next.removeSignatures).toBe(true);
|
||||
expect(matchHyperPreset(next)).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
describe('targetSizeToBytes', () => {
|
||||
it('converts KB and MB', () => {
|
||||
expect(targetSizeToBytes(100, 'kb')).toBe(102400);
|
||||
expect(targetSizeToBytes(1.5, 'mb')).toBe(1572864);
|
||||
});
|
||||
|
||||
it('treats empty, zero, negative and tiny values as no target', () => {
|
||||
expect(targetSizeToBytes(Number(''), 'mb')).toBeUndefined();
|
||||
expect(targetSizeToBytes(0, 'kb')).toBeUndefined();
|
||||
expect(targetSizeToBytes(-5, 'kb')).toBeUndefined();
|
||||
expect(targetSizeToBytes(Number.NaN, 'kb')).toBeUndefined();
|
||||
expect(targetSizeToBytes(0.0001, 'kb')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function textBuffer(text: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(text).buffer;
|
||||
}
|
||||
|
||||
describe('hasPdfHeader', () => {
|
||||
it('finds the header at the start or within the first 1024 bytes', () => {
|
||||
expect(hasPdfHeader(textBuffer('%PDF-1.7\n'))).toBe(true);
|
||||
expect(hasPdfHeader(textBuffer(`${' '.repeat(500)}%PDF-1.4`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty files, other formats and late headers', () => {
|
||||
expect(hasPdfHeader(new ArrayBuffer(0))).toBe(false);
|
||||
expect(hasPdfHeader(textBuffer('PK zip archive'))).toBe(false);
|
||||
expect(hasPdfHeader(textBuffer(`${' '.repeat(1024)}%PDF-1.7`))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeWorker {
|
||||
static instances: FakeWorker[] = [];
|
||||
onmessage: ((event: MessageEvent<HyperCompressResponse>) => void) | null =
|
||||
null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
onmessageerror: (() => void) | null = null;
|
||||
posted: { message: HyperCompressRequest; transfer: ArrayBuffer[] }[] = [];
|
||||
terminated = false;
|
||||
|
||||
constructor() {
|
||||
FakeWorker.instances.push(this);
|
||||
}
|
||||
|
||||
postMessage(message: HyperCompressRequest, transfer: ArrayBuffer[]) {
|
||||
this.posted.push({ message, transfer });
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
reply(message: HyperCompressResponse) {
|
||||
this.onmessage?.(new MessageEvent('message', { data: message }));
|
||||
}
|
||||
|
||||
lastId(): number {
|
||||
return this.posted[this.posted.length - 1].message.id;
|
||||
}
|
||||
}
|
||||
|
||||
const sampleResult: HyperWasmResult = {
|
||||
data: new Uint8Array([1, 2, 3]),
|
||||
originalSize: 10,
|
||||
compressedSize: 3,
|
||||
signed: false,
|
||||
signaturesRemoved: false,
|
||||
pdfa: null,
|
||||
pdfaOutcome: 'none',
|
||||
metTarget: null,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe('Hyper worker bridge', () => {
|
||||
let bridge: typeof import('../js/utils/compress');
|
||||
|
||||
beforeEach(async () => {
|
||||
FakeWorker.instances = [];
|
||||
vi.stubGlobal('Worker', FakeWorker);
|
||||
vi.resetModules();
|
||||
bridge = await import('../js/utils/compress');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('hands the PDF to the worker without copying and returns the result', async () => {
|
||||
const pdf = textBuffer('%PDF-1.7 body');
|
||||
const pending = bridge.compressWithHyper(pdf, { preset: 'high' });
|
||||
const worker = FakeWorker.instances[0];
|
||||
expect(worker.posted[0].transfer).toEqual([pdf]);
|
||||
expect(worker.posted[0].message).toMatchObject({
|
||||
type: 'compress',
|
||||
options: { preset: 'high' },
|
||||
});
|
||||
worker.reply({ id: worker.lastId(), type: 'result', result: sampleResult });
|
||||
await expect(pending).resolves.toBe(sampleResult);
|
||||
});
|
||||
|
||||
it('reports a wrong password as decrypt_failed and keeps the worker', async () => {
|
||||
const pending = bridge.compressWithHyper(textBuffer('%PDF-1.7'), {
|
||||
password: 'nope',
|
||||
});
|
||||
const worker = FakeWorker.instances[0];
|
||||
worker.reply({
|
||||
id: worker.lastId(),
|
||||
type: 'error',
|
||||
code: 'decrypt_failed',
|
||||
message: 'wrong or missing password',
|
||||
});
|
||||
await expect(pending).rejects.toMatchObject({ code: 'decrypt_failed' });
|
||||
expect(worker.terminated).toBe(false);
|
||||
|
||||
const next = bridge.verifyHyperPassword(textBuffer('%PDF-1.7'), 'x');
|
||||
expect(FakeWorker.instances).toHaveLength(1);
|
||||
worker.reply({ id: worker.lastId(), type: 'verified', valid: true });
|
||||
await expect(next).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('starts a fresh worker after an engine error', async () => {
|
||||
const pending = bridge.compressWithHyper(textBuffer('%PDF-1.7'), {});
|
||||
const worker = FakeWorker.instances[0];
|
||||
worker.reply({
|
||||
id: worker.lastId(),
|
||||
type: 'error',
|
||||
code: 'engine_error',
|
||||
message: 'boom',
|
||||
});
|
||||
await expect(pending).rejects.toBeInstanceOf(HyperError);
|
||||
expect(worker.terminated).toBe(true);
|
||||
|
||||
void bridge.verifyHyperPassword(textBuffer('%PDF-1.7'), '');
|
||||
expect(FakeWorker.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fails every pending job when the worker crashes', async () => {
|
||||
const first = bridge.compressWithHyper(textBuffer('%PDF-1.7'), {});
|
||||
const second = bridge.verifyHyperPassword(textBuffer('%PDF-1.7'), '');
|
||||
const worker = FakeWorker.instances[0];
|
||||
worker.onerror?.(new ErrorEvent('error', { message: 'out of memory' }));
|
||||
await expect(first).rejects.toMatchObject({
|
||||
code: 'engine_error',
|
||||
message: 'out of memory',
|
||||
});
|
||||
await expect(second).rejects.toMatchObject({ code: 'engine_error' });
|
||||
expect(worker.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a file that is not a PDF without starting the engine', async () => {
|
||||
await expect(
|
||||
bridge.compressWithHyper(textBuffer('just text'), {})
|
||||
).rejects.toMatchObject({ message: 'This file is not a valid PDF.' });
|
||||
expect(FakeWorker.instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PDFDocument, PDFName, rgb } from 'pdf-lib';
|
||||
|
||||
const { isWasmAvailable } = vi.hoisted(() => ({
|
||||
isWasmAvailable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../js/config/wasm-cdn-config.js', () => ({
|
||||
isWasmAvailable,
|
||||
getWasmBaseUrl: () => 'https://cdn.example/gs/assets/',
|
||||
}));
|
||||
|
||||
const { normalizeCmykForDocx, pdfUsesCmyk, setCachedGsModule } =
|
||||
await import('../js/utils/ghostscript-loader');
|
||||
|
||||
const RGB_OUTPUT = new TextEncoder().encode('%PDF-1.4 rgb output');
|
||||
|
||||
function fakeGhostscript(exitCode = 0) {
|
||||
const files = new Map<string, Uint8Array>();
|
||||
const calls: string[][] = [];
|
||||
const module = {
|
||||
FS: {
|
||||
writeFile(path: string, data: Uint8Array | string) {
|
||||
files.set(
|
||||
path,
|
||||
typeof data === 'string' ? new TextEncoder().encode(data) : data
|
||||
);
|
||||
},
|
||||
readFile(path: string) {
|
||||
const data = files.get(path);
|
||||
if (!data) throw new Error(`no file ${path}`);
|
||||
return data;
|
||||
},
|
||||
unlink(path: string) {
|
||||
if (!files.delete(path)) throw new Error(`no file ${path}`);
|
||||
},
|
||||
stat(path: string) {
|
||||
return { size: files.get(path)?.length ?? 0 };
|
||||
},
|
||||
},
|
||||
callMain(args: string[]) {
|
||||
calls.push(args);
|
||||
const output = args.find((arg) => arg.startsWith('-sOutputFile='));
|
||||
if (exitCode === 0 && output) {
|
||||
files.set(output.slice('-sOutputFile='.length), RGB_OUTPUT);
|
||||
}
|
||||
return exitCode;
|
||||
},
|
||||
};
|
||||
setCachedGsModule(module);
|
||||
return { files, calls };
|
||||
}
|
||||
|
||||
async function cmykImagePdf(): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([200, 200]);
|
||||
const image = doc.context.register(
|
||||
doc.context.flateStream(new Uint8Array(4 * 4 * 4).fill(128), {
|
||||
Type: 'XObject',
|
||||
Subtype: 'Image',
|
||||
Width: 4,
|
||||
Height: 4,
|
||||
ColorSpace: 'DeviceCMYK',
|
||||
BitsPerComponent: 8,
|
||||
})
|
||||
);
|
||||
page.node.set(
|
||||
PDFName.of('Resources'),
|
||||
doc.context.obj({ XObject: { Im1: image } })
|
||||
);
|
||||
page.node.set(
|
||||
PDFName.of('Contents'),
|
||||
doc.context.register(
|
||||
doc.context.flateStream('q 100 0 0 100 50 50 cm /Im1 Do Q')
|
||||
)
|
||||
);
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
async function rgbPdf(): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage().drawRectangle({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
color: rgb(1, 0, 0),
|
||||
});
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
function pdfBlob(bytes: Uint8Array): Blob {
|
||||
return new Blob([new Uint8Array(bytes)], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isWasmAvailable.mockReset();
|
||||
isWasmAvailable.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('pdfUsesCmyk', () => {
|
||||
it('spots CMYK images, 4-channel ICC profiles and spot colors', async () => {
|
||||
const text = (value: string) => new TextEncoder().encode(value);
|
||||
expect(pdfUsesCmyk(await cmykImagePdf())).toBe(true);
|
||||
expect(pdfUsesCmyk(text('<< /N 4 /Length 3144 >>'))).toBe(true);
|
||||
expect(
|
||||
pdfUsesCmyk(text('[/Separation /PANTONE#20185 /DeviceRGB 5 0 R]'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
pdfUsesCmyk(text('[/DeviceN [/Cyan /Black] /DeviceRGB 5 0 R]'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores RGB-only PDFs and look-alike keys', async () => {
|
||||
expect(pdfUsesCmyk(await rgbPdf())).toBe(false);
|
||||
const text = (value: string) => new TextEncoder().encode(value);
|
||||
expect(pdfUsesCmyk(text('<< /N 40 /Separations 2 >>'))).toBe(false);
|
||||
expect(pdfUsesCmyk(text('<< /Type /ObjStm /N 4 /First 20 >>'))).toBe(false);
|
||||
expect(pdfUsesCmyk(text('<< /Linearized 1 /L 9000 /N 4 /T 8000 >>'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCmykForDocx', () => {
|
||||
it('converts a CMYK PDF to RGB with Ghostscript in safe mode', async () => {
|
||||
const gs = fakeGhostscript();
|
||||
const output = await normalizeCmykForDocx(pdfBlob(await cmykImagePdf()));
|
||||
|
||||
expect(Array.from(new Uint8Array(await output.arrayBuffer()))).toEqual(
|
||||
Array.from(RGB_OUTPUT)
|
||||
);
|
||||
expect(gs.calls).toHaveLength(1);
|
||||
expect(gs.calls[0]).toContain('-dSAFER');
|
||||
expect(gs.calls[0]).not.toContain('-dNOSAFER');
|
||||
expect(gs.calls[0]).toContain('-sColorConversionStrategy=sRGB');
|
||||
expect(gs.files.size).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves a PDF without CMYK untouched and never starts Ghostscript', async () => {
|
||||
const gs = fakeGhostscript();
|
||||
const input = pdfBlob(await rgbPdf());
|
||||
|
||||
expect(await normalizeCmykForDocx(input)).toBe(input);
|
||||
expect(gs.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falls back to the original PDF when Ghostscript fails', async () => {
|
||||
const gs = fakeGhostscript(1);
|
||||
const input = pdfBlob(await cmykImagePdf());
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(await normalizeCmykForDocx(input)).toBe(input);
|
||||
expect(gs.files.size).toBe(0);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('skips the conversion when Ghostscript is not configured', async () => {
|
||||
const gs = fakeGhostscript();
|
||||
isWasmAvailable.mockReturnValue(false);
|
||||
const input = pdfBlob(await cmykImagePdf());
|
||||
|
||||
expect(await normalizeCmykForDocx(input)).toBe(input);
|
||||
expect(gs.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { PDFDocument, PDFName, StandardFonts } from 'pdf-lib';
|
||||
import type { HyperWasmOptions } from 'hyper-compress-wasm';
|
||||
import type {
|
||||
HyperCompressRequest,
|
||||
HyperCompressResponse,
|
||||
QpdfInstanceExtended,
|
||||
} from '@/types';
|
||||
|
||||
interface WorkerScope {
|
||||
onmessage: ((event: { data: HyperCompressRequest }) => Promise<void>) | null;
|
||||
postMessage: (message: HyperCompressResponse) => void;
|
||||
}
|
||||
|
||||
const replies: HyperCompressResponse[] = [];
|
||||
const scope: WorkerScope = {
|
||||
onmessage: null,
|
||||
postMessage: (message) => {
|
||||
replies.push(message);
|
||||
},
|
||||
};
|
||||
vi.stubGlobal('self', scope);
|
||||
await import('../js/utils/hyper-compress.worker');
|
||||
|
||||
const USER_PASSWORD = 'user-pass';
|
||||
const OWNER_PASSWORD = 'owner-pass';
|
||||
const QPDF_WASM = resolve(
|
||||
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
|
||||
);
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
async function ask(
|
||||
request: HyperCompressRequest
|
||||
): Promise<HyperCompressResponse> {
|
||||
if (!scope.onmessage) throw new Error('the worker did not start');
|
||||
await scope.onmessage({ data: request });
|
||||
const reply = replies.find((message) => message.id === request.id);
|
||||
if (!reply) throw new Error(`no reply to request ${request.id}`);
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function verify(pdf: Uint8Array, password: string): Promise<boolean> {
|
||||
const reply = await ask({
|
||||
id: ++nextId,
|
||||
type: 'verify',
|
||||
bytes: new Uint8Array(pdf).buffer,
|
||||
password,
|
||||
});
|
||||
if (reply.type !== 'verified') throw new Error(`unexpected ${reply.type}`);
|
||||
return reply.valid;
|
||||
}
|
||||
|
||||
function compress(
|
||||
pdf: Uint8Array,
|
||||
options: HyperWasmOptions = {}
|
||||
): Promise<HyperCompressResponse> {
|
||||
return ask({
|
||||
id: ++nextId,
|
||||
type: 'compress',
|
||||
bytes: new Uint8Array(pdf).buffer,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
async function qpdf(input: Uint8Array, args: string[]): Promise<Uint8Array> {
|
||||
const { default: createModule } = await import('@neslinesli93/qpdf-wasm');
|
||||
const options = {
|
||||
locateFile: () => QPDF_WASM,
|
||||
wasmBinary: readFileSync(QPDF_WASM),
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
};
|
||||
const instance = (await createModule(
|
||||
options
|
||||
)) as unknown as QpdfInstanceExtended;
|
||||
instance.FS.writeFile('/in.pdf', input);
|
||||
instance.callMain(['/in.pdf', ...args, '/out.pdf']);
|
||||
return instance.FS.readFile('/out.pdf');
|
||||
}
|
||||
|
||||
async function textPdf(): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
for (const label of ['First page', 'Second page']) {
|
||||
doc.addPage().drawText(label, { x: 50, y: 700, font, size: 24 });
|
||||
}
|
||||
return doc.save({ useObjectStreams: false });
|
||||
}
|
||||
|
||||
function lock(pdf: Uint8Array, userPassword = USER_PASSWORD) {
|
||||
return qpdf(pdf, ['--encrypt', userPassword, OWNER_PASSWORD, '256', '--']);
|
||||
}
|
||||
|
||||
function shiftEveryOffset(pdf: Uint8Array): Uint8Array {
|
||||
const headerEnd = pdf.indexOf(0x0a) + 1;
|
||||
const junk = new TextEncoder().encode('%junk\n');
|
||||
const out = new Uint8Array(pdf.length + junk.length);
|
||||
out.set(pdf.subarray(0, headerEnd));
|
||||
out.set(junk, headerEnd);
|
||||
out.set(pdf.subarray(headerEnd), headerEnd + junk.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function signedPdf(): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage();
|
||||
const signature = doc.context.register(
|
||||
doc.context.obj({ Type: 'Sig', Filter: 'Adobe.PPKLite' })
|
||||
);
|
||||
doc.catalog.set(PDFName.of('Perms'), doc.context.obj({ DocMDP: signature }));
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
function hasEncryptDictionary(pdf: Uint8Array): boolean {
|
||||
return new TextDecoder('latin1').decode(pdf).includes('/Encrypt');
|
||||
}
|
||||
|
||||
describe('Hyper compress worker', { timeout: 60_000 }, () => {
|
||||
it('opens an unencrypted PDF without a password', async () => {
|
||||
expect(await verify(await textPdf(), '')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts only the user or owner password of a locked PDF', async () => {
|
||||
const locked = await lock(await textPdf());
|
||||
expect(await verify(locked, '')).toBe(false);
|
||||
expect(await verify(locked, 'not-it')).toBe(false);
|
||||
expect(await verify(locked, USER_PASSWORD)).toBe(true);
|
||||
expect(await verify(locked, OWNER_PASSWORD)).toBe(true);
|
||||
});
|
||||
|
||||
it('turns a locked PDF into an unlocked, readable copy', async () => {
|
||||
const reply = await compress(await lock(await textPdf()), {
|
||||
preset: 'medium',
|
||||
password: USER_PASSWORD,
|
||||
});
|
||||
if (reply.type !== 'result') throw new Error(`unexpected ${reply.type}`);
|
||||
expect(hasEncryptDictionary(reply.result.data)).toBe(false);
|
||||
const output = await PDFDocument.load(reply.result.data);
|
||||
expect(output.getPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('refuses a locked PDF without its password', async () => {
|
||||
const reply = await compress(await lock(await textPdf()), {
|
||||
preset: 'medium',
|
||||
});
|
||||
expect(reply).toMatchObject({ type: 'error', code: 'decrypt_failed' });
|
||||
});
|
||||
|
||||
it('refuses a locked PDF with the wrong password', async () => {
|
||||
const reply = await compress(await lock(await textPdf()), {
|
||||
preset: 'medium',
|
||||
password: 'not-it',
|
||||
});
|
||||
expect(reply).toMatchObject({ type: 'error', code: 'decrypt_failed' });
|
||||
});
|
||||
|
||||
it('unlocks older encryption whose password has non-ASCII characters', async () => {
|
||||
const password = 'pässwört';
|
||||
const encryptions = [
|
||||
['--encrypt', password, OWNER_PASSWORD, '128', '--use-aes=y', '--'],
|
||||
[
|
||||
'--allow-weak-crypto',
|
||||
'--encrypt',
|
||||
password,
|
||||
OWNER_PASSWORD,
|
||||
'40',
|
||||
'--',
|
||||
],
|
||||
];
|
||||
for (const args of encryptions) {
|
||||
const locked = await qpdf(await textPdf(), args);
|
||||
expect(await verify(locked, password)).toBe(true);
|
||||
expect(await verify(locked, 'passwort')).toBe(false);
|
||||
const reply = await compress(locked, { preset: 'medium', password });
|
||||
if (reply.type !== 'result') throw new Error(`unexpected ${reply.type}`);
|
||||
expect(hasEncryptDictionary(reply.result.data)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('repairs and unlocks a damaged locked PDF', async () => {
|
||||
const damaged = shiftEveryOffset(await lock(await textPdf()));
|
||||
expect(await verify(damaged, USER_PASSWORD)).toBe(true);
|
||||
const reply = await compress(damaged, {
|
||||
preset: 'medium',
|
||||
password: USER_PASSWORD,
|
||||
});
|
||||
if (reply.type !== 'result') throw new Error(`unexpected ${reply.type}`);
|
||||
const output = await PDFDocument.load(reply.result.data);
|
||||
expect(output.getPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps owner-only protection on a PDF that opens without a password', async () => {
|
||||
const ownerOnly = await lock(await textPdf(), '');
|
||||
expect(await verify(ownerOnly, '')).toBe(true);
|
||||
const reply = await compress(ownerOnly, { preset: 'medium' });
|
||||
if (reply.type !== 'result') throw new Error(`unexpected ${reply.type}`);
|
||||
expect(hasEncryptDictionary(reply.result.data)).toBe(true);
|
||||
expect(await verify(reply.result.data, '')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a signed PDF unchanged', async () => {
|
||||
const signed = await signedPdf();
|
||||
const reply = await compress(signed, { preset: 'high' });
|
||||
if (reply.type !== 'result') throw new Error(`unexpected ${reply.type}`);
|
||||
expect(reply.result.signed).toBe(true);
|
||||
expect(Array.from(reply.result.data)).toEqual(Array.from(signed));
|
||||
});
|
||||
|
||||
it('returns a valid PDF when the target size cannot be reached', async () => {
|
||||
const reply = await compress(await textPdf(), {
|
||||
preset: 'medium',
|
||||
targetSizeBytes: 100,
|
||||
});
|
||||
if (reply.type !== 'result') throw new Error(`unexpected ${reply.type}`);
|
||||
expect(reply.result.metTarget).toBe(false);
|
||||
const output = await PDFDocument.load(reply.result.data);
|
||||
expect(output.getPageCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const STORAGE_KEY = 'bentopdf:wasm-providers';
|
||||
const CURRENT_PYMUPDF =
|
||||
'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/';
|
||||
|
||||
async function loadProvider(stored?: Record<string, string>) {
|
||||
localStorage.clear();
|
||||
if (stored) localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
||||
vi.resetModules();
|
||||
return (await import('../js/utils/wasm-provider')).WasmProvider;
|
||||
}
|
||||
|
||||
function storedConfig(): Record<string, string> {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('WasmProvider saved URLs', () => {
|
||||
it('replaces an outdated CDN version saved in the browser with the current one', async () => {
|
||||
const provider = await loadProvider({
|
||||
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/',
|
||||
ghostscript:
|
||||
'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/',
|
||||
cpdf: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/',
|
||||
});
|
||||
|
||||
expect(provider.getUrl('pymupdf')).toBe(CURRENT_PYMUPDF);
|
||||
expect(provider.getUrl('ghostscript')).toBe(
|
||||
'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/assets/'
|
||||
);
|
||||
expect(provider.getUrl('cpdf')).toBe(
|
||||
'https://cdn.jsdelivr.net/npm/[email protected]/dist/'
|
||||
);
|
||||
expect(provider.isUserConfigured('pymupdf')).toBe(false);
|
||||
expect(storedConfig()).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps a self-hosted URL the user chose', async () => {
|
||||
const provider = await loadProvider({
|
||||
pymupdf: 'http://localhost/wasm/pymupdf/',
|
||||
});
|
||||
|
||||
expect(provider.getUrl('pymupdf')).toBe('http://localhost/wasm/pymupdf/');
|
||||
expect(storedConfig().pymupdf).toBe('http://localhost/wasm/pymupdf/');
|
||||
});
|
||||
|
||||
it('does not pin the default when it is saved, so later updates still apply', async () => {
|
||||
const provider = await loadProvider();
|
||||
|
||||
provider.setUrl('pymupdf', CURRENT_PYMUPDF.slice(0, -1));
|
||||
expect(storedConfig().pymupdf).toBeUndefined();
|
||||
expect(provider.isUserConfigured('pymupdf')).toBe(false);
|
||||
expect(provider.getUrl('pymupdf')).toBe(CURRENT_PYMUPDF);
|
||||
|
||||
provider.setUrl('pymupdf', 'http://localhost/wasm/pymupdf');
|
||||
expect(storedConfig().pymupdf).toBe('http://localhost/wasm/pymupdf/');
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,7 @@ describe('workflow import validation', () => {
|
||||
},
|
||||
{ type: 'NUpNode', controls: { pagesPerSheet: 7 } },
|
||||
{ type: 'RotateNode', controls: { angle: '45' } },
|
||||
{ type: 'CompressNode', controls: { algorithm: 'photon' } },
|
||||
{ type: 'CompressNode', controls: { compressionLevel: 'aggressive' } },
|
||||
])
|
||||
);
|
||||
|
||||
@@ -116,9 +116,9 @@ describe('workflow import validation', () => {
|
||||
expect(controlValue(redact, 'text')).toBe('secret');
|
||||
expect(controlValue(findNode(nodes, 'NUpNode'), 'pagesPerSheet')).toBe(4);
|
||||
expect(controlValue(findNode(nodes, 'RotateNode'), 'angle')).toBe('90');
|
||||
expect(controlValue(findNode(nodes, 'CompressNode'), 'algorithm')).toBe(
|
||||
'photon'
|
||||
);
|
||||
expect(
|
||||
controlValue(findNode(nodes, 'CompressNode'), 'compressionLevel')
|
||||
).toBe('high');
|
||||
});
|
||||
|
||||
it('keeps only known multi-select values', async () => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
v1.0.3
|
||||
Binary file not shown.
+6
-1
@@ -617,7 +617,12 @@ export default defineConfig(() => {
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['pdfkit', 'blob-stream'],
|
||||
exclude: ['coherentpdf', 'wasm-vips', 'bentopdf-pdfium'],
|
||||
exclude: [
|
||||
'coherentpdf',
|
||||
'wasm-vips',
|
||||
'bentopdf-pdfium',
|
||||
'hyper-compress-wasm',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
host: process.env.VITE_DEV_HOST || 'localhost',
|
||||
|
||||
Reference in New Issue
Block a user