From 66e14f72c3250a8e36ab8cc41e11ccb28ce27bce Mon Sep 17 00:00:00 2001 From: allebonvi Date: Thu, 17 Sep 2026 16:22:18 +0200 Subject: [PATCH] Release ZipBundler 1.0.0 with guided recompression and complete Windows build --- README.md | 159 ++++++++++++++-- RELEASE_NOTES.md | 54 ++++++ build_exe.ps1 | 35 +++- test_pdf_workflow.py | 239 ++++++++++++++++++++++++ verify_exe.py | 86 +++++++++ zipbundler.py | 433 ++++++++++++++++++++++++++++++------------- 6 files changed, 851 insertions(+), 155 deletions(-) create mode 100644 RELEASE_NOTES.md create mode 100644 test_pdf_workflow.py create mode 100644 verify_exe.py diff --git a/README.md b/README.md index e983f62..afab2ed 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,66 @@ Questo e importante perche la dimensione dei file originali non coincide con la ## PDF troppo grandi -Quando un PDF supera da solo il limite impostato, ZipBundler prova diversi approcci: +Quando un PDF supera da solo il limite impostato, ZipBundler prova questi +profili standard nell'ordine indicato, fermandosi al primo ZIP entro limite: -1. Ottimizzazione interna senza perdita tramite PyMuPDF. -2. Compressione tramite Ghostscript, se disponibile nel sistema. -3. Ricodifica a immagini/DPI ridotti solo per PDF che non sembrano contenere testo selezionabile significativo. +| Ordine | Profilo | Operazione | Qualita JPEG | +| --- | --- | --- | --- | +| 1 | Ottimizzazione senza perdita | Pulizia e compressione interna con PyMuPDF, senza rasterizzare le pagine | Non applicabile | +| 2 | Ghostscript ebook | Preset `/ebook` | Gestita dal preset | +| 3 | Ghostscript screen | Preset `/screen` | Gestita dal preset | +| 4 | Qualita email | Pagine trasformate in immagini a 150 DPI | 68/100 | +| 5 | Piu leggero | Pagine trasformate in immagini a 120 DPI | 60/100 | +| 6 | Minimo | Pagine trasformate in immagini a 100 DPI | 54/100 | + +I profili 4-6 vengono saltati quando viene rilevato testo selezionabile +significativo. I profili Ghostscript vengono saltati se il motore non e +disponibile; nella build completa e incluso nell'EXE. Anche i profili standard +possono ridurre sensibilmente la qualita: se rientrano nel limite vengono +accettati automaticamente, secondo la regola scelta per il programma. La ricodifica raster dei PDF nativi e evitata quando possibile, perche puo aumentare la dimensione e far perdere testo selezionabile. Se viene creata una copia ridotta, l'originale non viene modificato. La copia viene salvata nella cartella di destinazione, dentro `_pdf_ridotti_email`. -Nella GUI viene mostrata una preview affiancata originale/ridotto, con navigazione delle pagine. +Quando serve una scelta sulla qualita, la GUI mostra una preview affiancata originale/ridotto, con navigazione delle pagine. + +### Compressione aggiuntiva e zoom + +La regola comune a PDF, immagini e conversioni Office e basata sulla dimensione +reale dello ZIP del singolo file: + +1. Se un profilo standard rientra nel limite, la copia viene usata automaticamente, senza anteprima o domande. +2. Se nessun profilo standard rientra, viene conservato il risultato con lo ZIP piu piccolo e mostrato in anteprima, indicando dimensione e limite. +3. L'utente puo accettare esplicitamente quel risultato fuori limite oppure scegliere "Rifiuta e comprimi di piu". +4. Ogni tentativo aggiuntivo richiede anteprima e approvazione, anche quando finalmente rientra nel limite. Ogni rifiuto passa al tentativo successivo. + +Non esiste piu il ripiego automatico all'originale o al profilo 200 DPI. +La chiusura dell'anteprima o "Annulla elaborazione" interrompe il lavoro senza +creare nuovi ZIP. All'ultimo profilo disponibile rimangono accettazione e +annullamento: nessun risultato rifiutato viene impacchettato e non viene avviato +un ciclo infinito. Il limite potrebbe essere inferiore alla dimensione minima +ottenibile di un documento valido. + +Per i PDF, dopo ciascun rifiuto vengono proposti profili Ghostscript a 72, 60, +48, 36 e 24 DPI, poi profili raster alle stesse risoluzioni, con qualita JPEG +45, 38, 30, 24 e 18. L'anteprima indica il prossimo tentativo, avvertendo quando +le pagine verranno trasformate in immagini con perdita del testo selezionabile. Ogni +tentativo parte dall'originale e viene misurato dentro uno ZIP contenente quel +solo PDF. I parametri Ghostscript usano i controlli di downsampling e i dizionari +di compressione documentati in [High Level Devices](https://ghostscript.readthedocs.io/en/gs10.02.1/VectorDevices.html). + +L'anteprima PDF dispone di modalita Adatta e zoom dal 50% +al 400%, con barre di scorrimento sincronizzate tra originale e copia. Il 100% +corrisponde a 96 pixel per pollice del documento; la pagina viene renderizzata +nuovamente a ogni livello di zoom. La rotella scorre verticalmente; con Shift +scorre orizzontalmente. La navigazione delle pagine mantiene lo zoom scelto. + +Gli originali restano intatti e la ricodifica raster conserva le dimensioni +fisiche delle pagine. In modalita CLI, se i profili standard non bastano, +l'elaborazione si interrompe invitando a usare la GUI. Se un file fuori limite +non ha un convertitore disponibile o abilitato, il programma segnala il problema +e si interrompe anziche creare un pacchetto fuori limite non approvato. ## Ghostscript @@ -62,11 +111,25 @@ Formati supportati: - BMP - WEBP -Profili: +Profili standard automatici, nell'ordine: -- qualita alta: lato lungo massimo 2500 px -- qualita email: lato lungo massimo 1800 px -- massima compressione: lato lungo massimo 1200 px +| Profilo | Lato lungo massimo | Qualita JPEG/WebP | +| --- | --- | --- | +| Qualita alta | 2500 pixel | 85/100 | +| Qualita email | 1800 pixel | 75/100 | +| Massima compressione | 1200 pixel | 60/100 | + +Le proporzioni vengono mantenute e le immagini piu piccole non vengono +ingrandite. I valori di qualita sono parametri dell'encoder, non percentuali +di dimensione risparmiata. La codifica applicata dipende dal formato: + +| Formato | Codifica della copia ridotta | +| --- | --- | +| JPEG | JPEG ottimizzato e progressivo, qualita del profilo | +| WebP | Qualita del profilo, metodo di compressione 6 | +| PNG | Compressione senza perdita, livello 9; riduzione dei pixel secondo il profilo | +| TIFF | Deflate (`tiff_adobe_deflate`), riduzione dei pixel secondo il profilo | +| BMP | BMP, riduzione dei pixel secondo il profilo | Per impostazione predefinita il formato originale viene mantenuto. Per esempio un TIFF ridotto resta TIFF. @@ -78,6 +141,14 @@ Solo se questa opzione e attiva, BMP e TIFF vengono convertiti in JPEG per otten Le copie ridotte vengono salvate in `_immagini_ridotte_email`. +Se i tre profili standard non bastano, il ciclo con anteprima propone lati +massimi di 1000, 800, 600, 400, 200 e 100 pixel, con qualita JPEG/WebP 50, 40, +30, 24, 18 e 12. Per PNG, TIFF e BMP la riduzione aggiuntiva si basa soprattutto +sul numero di pixel; la scelta di conservare il formato resta valida. +L'avviso Pillow per immagini molto grandi viene riportato nel registro, senza +disabilitare il limite massimo di protezione della libreria. Per i JPEG viene +richiesta la decodifica ridotta prima del ridimensionamento, limitando la memoria. + ## File Office troppo grandi ZipBundler puo convertire file Office grandi in PDF, ma solo se la checkbox dedicata e attiva: @@ -92,17 +163,48 @@ Formati previsti: La conversione usa Microsoft Office tramite automazione COM (`win32com`). Quindi richiede Office installato e funzionante sul PC. +Non esistono profili di qualita Office separati: + +| Documento | Passaggio standard | +| --- | --- | +| DOC/DOCX | Esportazione PDF tramite Microsoft Word | +| XLS/XLSX | Esportazione PDF tramite Microsoft Excel | +| PPT/PPTX | Esportazione PDF tramite Microsoft PowerPoint | + Le copie PDF vengono salvate in `_office_convertiti_pdf`. +Se il PDF convertito entra nello ZIP, viene usato senza domande. Altrimenti +passa per l'ottimizzazione PDF standard e, se ancora necessario, per lo stesso +ciclo di anteprime e compressioni aggiuntive. L'anteprima confronta il PDF +ottenuto da Office con la sua copia ricompressa. + LibreOffice headless e stato valutato come opzione futura, ma non e incluso per evitare di aumentare molto il peso di un eventuale eseguibile PyInstaller. +## Altri formati + +Per i formati non elencati viene applicata soltanto la compressione ZIP, senza +ricodifica del contenuto. Se il file resta fuori limite e non esiste una +conversione disponibile o abilitata, il programma segnala il problema. + ## Scelte conservative - Gli originali non vengono mai modificati. - Le conversioni di formato sono disattivate di default. -- Una copia ottimizzata viene accettata solo se riduce davvero il file e permette allo ZIP finale di rientrare nel limite. +- I risultati dei profili standard entro limite vengono accettati automaticamente. - Sopra 18 MB viene richiesto consenso esplicito, perche la codifica email puo aumentare la dimensione effettiva del messaggio. -- Se un file non puo essere ridotto sotto soglia, viene segnalato e inserito in un pacchetto fuori limite. +- Uno ZIP fuori limite viene creato solo dopo approvazione esplicita e contiene soltanto il file accettato. + +## Verifica del flusso PDF + +```powershell +python -m unittest test_pdf_workflow -v +``` + +I test verificano l'accettazione automatica dei profili standard entro limite, +il ciclo rifiuto/ricompressione/anteprima, l'annullamento, il mantenimento dei +formati immagine, i file effettivamente inseriti negli ZIP, lo zoom e gli avvisi +Pillow. Se Ghostscript e disponibile, verificano anche la ricodifica reale con +conservazione del testo selezionabile. I test Office simulano la conversione COM. ## Uso GUI @@ -137,9 +239,12 @@ Dipendenze principali: Per Office COM serve anche `pywin32`, installato nell'ambiente in cui viene eseguito il programma. -## Packaging futuro +## Eseguibile Windows -Il progetto e pensato per essere trasformato in un eseguibile Windows con PyInstaller. +La build completa crea `dist\ZipBundler.exe`: un eseguibile portabile che +include Python, CustomTkinter/Tcl/Tk, Pillow, PyMuPDF, pywin32 e Ghostscript. +Sul PC di destinazione non serve installare Python o Ghostscript. Non sono +inclusi i documenti di prova in SDS. Script disponibile: @@ -150,21 +255,41 @@ Script disponibile: Lo script: - installa le dipendenze Python; -- installa/aggiorna PyInstaller; +- installa PyInstaller se necessario; - include lo splash screen `assets/splash.png`; - crea un eseguibile Windows `dist\ZipBundler.exe`; -- prova a includere Ghostscript se trova `gswin64c`, `gswin32c` o `gs` nel PATH. +- include Ghostscript se trova `gswin64c`, `gswin32c` o `gs` nel PATH; senza il motore la build completa si interrompe; +- controlla l'esito dei comandi prima di dichiarare completata la build. + +Per ricostruire usando le dipendenze gia installate: + +```powershell +.\build_exe.ps1 -SkipDependencyInstall +``` + +L'opzione `-SkipGhostscriptBundle` produce invece una versione incompleta del +motore PDF avanzato, che richiede Ghostscript esterno. Non e la build completa. + +Verifica della build completa, da eseguire sul PC di sviluppo: + +```powershell +python verify_exe.py +``` + +La verifica controlla i componenti incorporati nell'EXE e avvia il programma +con Python e Ghostscript esterni esclusi dal PATH. Crea un PDF con testo +selezionabile e una foto di prova, verifica gli ZIP finali sotto soglia, il +mantenimento del testo PDF e il formato JPEG. Usa solo cartelle temporanee. Lo splash screen riporta `ZipBundler`, `versione 1.0` e `ai.teamstudio.it`. Componenti inclusi o gestiti: - CustomTkinter, Pillow e PyMuPDF vengono inclusi da PyInstaller come dipendenze Python; -- Ghostscript viene incluso se rilevato in fase di build; +- Ghostscript e incluso nella build completa, con le risorse della sua installazione; - Microsoft Office non viene incluso: la conversione Office -> PDF usa Office COM e richiede Office installato sul PC di destinazione. Da valutare in futuro: -- gestione piu esplicita di `pywin32`; - eventuale icona applicativa; - firma dell'eseguibile, se necessaria in ambiente aziendale. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..c50effd --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,54 @@ +# ZipBundler 1.0.0 + +Prima release Windows completa di ZipBundler, con interfaccia versione 1.0. + +## Funzioni + +- ZIP indipendenti con limite verificato sulla dimensione finale del pacchetto. +- Selezione sorgente e destinazione, spinbox con valore iniziale 15 MB e avviso sopra 18 MB. +- Riduzione PDF con PyMuPDF e Ghostscript; ridimensionamento JPEG, PNG, TIFF, BMP e WebP. +- Risultati dei profili standard entro limite accettati automaticamente, senza domande. +- Se i profili standard non bastano: anteprima, accettazione esplicita oppure nuovo tentativo piu aggressivo. +- Ogni tentativo aggiuntivo richiede approvazione; nessun ripiego automatico all'originale o a 200 DPI dopo un rifiuto. +- Anteprima PDF affiancata con navigazione pagine, zoom fino al 400% e scorrimento sincronizzato. +- Formato immagine originale mantenuto; conversione BMP/TIFF in JPEG solo tramite checkbox. +- Conversione Office in PDF solo tramite checkbox; eventuale riduzione successiva con i profili PDF. +- Migliore gestione dei JPEG molto grandi e avviso Pillow riportato nel registro dell'app. +- Splash screen con ai.teamstudio.it. + +## Pacchetto Windows + +Scaricare e avviare `ZipBundler.exe`. L'eseguibile x64 include Python 3.14.5, +CustomTkinter/Tcl/Tk, Pillow, PyMuPDF, pywin32 e Ghostscript 10.07.0. Non serve +installare Python o Ghostscript sul PC di destinazione. + +Microsoft Office non e incluso: la conversione DOC/DOCX, XLS/XLSX e PPT/PPTX +richiede le rispettive applicazioni Office installate. LibreOffice non e incluso. + +Dimensione EXE: 102047893 byte (circa 102 MB decimali, 97,3 MiB). + +SHA-256 di `ZipBundler.exe`: + +```text +f94a1a520dfa5136c87a135e13f33002fa14c6b6e54541334104b7b089086907 +``` + +Il README allegato documenta i profili standard per tipo di documento, +il ciclo di compressione aggiuntiva, i limiti e la procedura di build. + +## Verifiche + +- 18 test automatici superati. +- Componenti incorporati nell'EXE verificati. +- Compressione reale di PDF e JPEG tramite EXE con Python e Ghostscript esterni esclusi dal PATH. +- ZIP prodotti entro limite, JPEG valido e testo PDF selezionabile conservato. +- Avvio della finestra `ZipBundler versione 1.0` verificato. +- Conversione Office verificata tramite simulazione nei test; non verificata con Office reale in questa build. + +Build effettuata con PyInstaller 6.21.0. Comandi riproducibili: + +```powershell +python -m unittest test_pdf_workflow -v +.\build_exe.ps1 -SkipDependencyInstall +python verify_exe.py +``` diff --git a/build_exe.ps1 b/build_exe.ps1 index 4690cf9..7b8abfc 100644 --- a/build_exe.ps1 +++ b/build_exe.ps1 @@ -1,6 +1,7 @@ param( [switch]$Clean, - [switch]$SkipGhostscriptBundle + [switch]$SkipGhostscriptBundle, + [switch]$SkipDependencyInstall ) $ErrorActionPreference = "Stop" @@ -21,14 +22,27 @@ if (-not (Test-Path -LiteralPath $SplashImage)) { } if ($Clean) { - Remove-Item -LiteralPath (Join-Path $ProjectRoot "build") -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath (Join-Path $ProjectRoot "dist") -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath (Join-Path $ProjectRoot "$AppName.spec") -Force -ErrorAction SilentlyContinue + foreach ($relativePath in @("build", "dist", "$AppName.spec")) { + $targetPath = [IO.Path]::GetFullPath((Join-Path $ProjectRoot $relativePath)) + if (-not $targetPath.StartsWith($ProjectRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw "Percorso di pulizia esterno al progetto: $targetPath" + } + if (Test-Path -LiteralPath $targetPath) { + $targetItem = Get-Item -LiteralPath $targetPath -Force + if ($targetItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Pulizia non consentita su collegamenti: $targetPath" + } + Remove-Item -LiteralPath $targetPath -Recurse -Force + } + } } -python -m pip install --upgrade pip -python -m pip install -r requirements.txt -python -m pip install --upgrade pyinstaller +if (-not $SkipDependencyInstall) { + python -m pip install -r requirements.txt + if ($LASTEXITCODE -ne 0) { throw "Installazione dipendenze non riuscita." } + python -m pip install pyinstaller + if ($LASTEXITCODE -ne 0) { throw "Installazione PyInstaller non riuscita." } +} $argsList = @( "--noconfirm", @@ -38,12 +52,14 @@ $argsList = @( "--name", $AppName, "--splash", $SplashImage, "--add-data", "$SplashImage;assets", + "--collect-all", "customtkinter", "--hidden-import", "customtkinter", "--hidden-import", "PIL._tkinter_finder", "--hidden-import", "fitz", "--hidden-import", "win32com", "--hidden-import", "win32com.client", - "--hidden-import", "pythoncom" + "--hidden-import", "pythoncom", + "--hidden-import", "pywintypes" ) if (-not $SkipGhostscriptBundle) { @@ -55,7 +71,7 @@ if (-not $SkipGhostscriptBundle) { $argsList += @("--add-data", "$ghostscriptRoot;ghostscript") } else { - Write-Warning "Ghostscript non trovato nel PATH. Il programma usera Ghostscript solo se installato sul PC di destinazione." + throw "Ghostscript non trovato nel PATH: impossibile creare la build completa." } } else { @@ -65,6 +81,7 @@ else { $argsList += $EntryPoint python -m PyInstaller @argsList +if ($LASTEXITCODE -ne 0) { throw "PyInstaller non ha completato la build." } $exePath = Join-Path $ProjectRoot "dist\$AppName.exe" if (Test-Path -LiteralPath $exePath) { diff --git a/test_pdf_workflow.py b/test_pdf_workflow.py new file mode 100644 index 0000000..f269869 --- /dev/null +++ b/test_pdf_workflow.py @@ -0,0 +1,239 @@ +import tempfile +import unittest +import warnings +import zipfile +from pathlib import Path +from unittest.mock import Mock, patch + +import fitz + +import zipbundler as zb + + +class CompressionWorkflowTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.source = self.root / "source.pdf" + with fitz.open() as doc: + for number in range(2): + page = doc.new_page(width=300, height=400) + page.insert_text((30, 40), f"Page {number + 1}: readable text") + doc.save(self.source) + self.file = zb.BundleFile(self.source, Path("source.pdf")) + self.out = self.root / "output" + + def report(self, size=5000, kind="pdf", dpi=72): + return zb.OptimizedPdf(self.file, self.file, 10000, size, kind, "test", dpi, 45, + zip_size=size, max_zip_size=1000) + + @staticmethod + def close_test_app(app): + for callback in app.tk.call("after", "info"): + app.after_cancel(callback) + app.destroy() + + def test_standard_fitting_result_never_asks_permission(self): + for kind in ("pdf", "image", "office"): + with self.subTest(kind=kind): + preview = Mock(side_effect=AssertionError("No dialog expected")) + expected = self.report(size=1000, kind=kind) + self.assertIs(zb.negotiate_compression(expected, [], 1000, preview), expected) + + def test_pdf_standard_success_is_automatic(self): + expected = self.report(size=500) + preview = Mock() + with patch.object(zb, "try_optimize_pdf_for_limit", return_value=expected): + report, separate = zb.choose_pdf_for_limit( + self.file, self.out, 1000, confirm_optimization=preview, + ) + self.assertIs(report, expected) + self.assertFalse(separate) + preview.assert_not_called() + + def test_oversized_standard_can_be_explicitly_accepted(self): + retry = Mock() + preview = Mock(return_value=True) + report = zb.negotiate_compression(self.report(), [("stronger", retry)], 1000, preview) + self.assertEqual(report.zip_size, 5000) + preview.assert_called_once() + retry.assert_not_called() + + def test_each_rejected_preview_advances_even_when_candidate_fits(self): + attempts = [("first", Mock(return_value=self.report(800))), + ("second", Mock(return_value=self.report(600)))] + preview = Mock(side_effect=[False, False, True]) + with patch.object(zb, "measured_report", side_effect=lambda r, limit: r): + result = zb.negotiate_compression(self.report(), attempts, 1000, preview) + self.assertEqual(result.zip_size, 600) + self.assertEqual([call.args[0].zip_size for call in preview.call_args_list], [5000, 800, 600]) + self.assertFalse(preview.call_args.args[0].can_retry) + for _, attempt in attempts: + attempt.assert_called_once() + + def test_cancelling_never_starts_another_conversion(self): + retry = Mock() + with self.assertRaises(zb.BundleCancelled): + zb.negotiate_compression(self.report(), [("stronger", retry)], 1000, lambda r: None) + retry.assert_not_called() + + def test_rejecting_last_profile_never_falls_back_to_original_or_200_dpi(self): + with patch.object(zb, "pdf_at_dpi") as convert, self.assertRaises(zb.BundleCancelled): + zb.negotiate_compression(self.report(), [], 1000, lambda r: False) + convert.assert_not_called() + + def test_noninteractive_oversized_result_is_not_silently_accepted(self): + with self.assertRaisesRegex(ValueError, "GUI"): + zb.negotiate_compression(self.report(), [], 1000, None) + + def test_best_standard_file_is_preserved_when_later_attempt_overwrites_it(self): + destination = self.root / "reduced.pdf" + expected_bytes = b"first and best" + + def attempt(content, size): + destination.write_bytes(content) + return zb.replace(self.report(size), optimized=zb.BundleFile(destination, Path("reduced.pdf"))) + + with patch.object(zb, "measured_report", side_effect=lambda r, limit: zb.replace( + r, zip_size=r.optimized_size, max_zip_size=limit)): + result = zb.select_standard_result(self.file, "pdf", [ + lambda: attempt(expected_bytes, 500), lambda: attempt(b"worse", 900), + ], 1) + self.assertEqual(result.zip_size, 500) + self.assertEqual(result.optimized.path.read_bytes(), expected_bytes) + + def test_jpeg_that_fits_is_bundled_without_preview(self): + source_folder = self.root / "photos" + source_folder.mkdir() + jpeg = source_folder / "large.jpg" + image = zb.Image.effect_noise((1000, 1400), 80) + image.save(jpeg, quality=98) + original = jpeg.read_bytes() + preview = Mock(side_effect=AssertionError("Unexpected preview")) + results = zb.bundle_folder(source_folder, self.out, 0.6, confirm_optimization=preview) + self.assertGreater(len(original), zb.mb_to_bytes(0.6)) + self.assertTrue(results) + for archive, _, _, size in results: + self.assertLessEqual(size, zb.mb_to_bytes(0.6)) + with zipfile.ZipFile(archive) as zipped: + self.assertTrue(all(name.endswith(".jpg") for name in zipped.namelist())) + self.assertNotEqual(zipped.read(zipped.namelist()[0]), original) + preview.assert_not_called() + self.assertEqual(jpeg.read_bytes(), original) + + def test_aggressive_images_preserve_format_and_require_preview(self): + noise = zb.Image.effect_noise((700, 900), 80) + for suffix, fmt in (("png", "PNG"), ("tif", "TIFF"), ("bmp", "BMP"), ("webp", "WEBP")): + with self.subTest(format=fmt): + path = self.root / f"image.{suffix}" + noise.save(path, format=fmt) + source = zb.BundleFile(path, Path(path.name)) + standard = zb.measured_report(zb.OptimizedPdf( + source, source, path.stat().st_size, path.stat().st_size, + "image", "originale", None, None, + ), 100) + preview = Mock(side_effect=[False, True]) + with patch.object(zb, "try_optimize_image_for_limit", return_value=standard): + report = zb.choose_image_for_limit(source, self.out, 100, confirm_optimization=preview) + self.assertEqual(preview.call_count, 2) + self.assertEqual(report.optimized.path.suffix, "." + suffix) + with zb.Image.open(report.optimized.path) as image: + self.assertEqual(image.format, fmt) + + def test_explicitly_accepted_oversized_result_is_alone_in_zip(self): + replacement = zb.optimize_pdf(self.file, self.out, "raster_72", 72, 45) + replacement = zb.measured_report(replacement, 150) + with patch.object(zb, "choose_pdf_for_limit", return_value=(replacement, True)): + groups, reports = zb.split_into_groups([self.file], self.out, 150) + self.assertEqual(groups, [[replacement.optimized]]) + archive = self.root / "accepted.zip" + zb.create_zip(archive, groups[0]) + with zipfile.ZipFile(archive) as zipped: + self.assertEqual(zipped.read(zipped.namelist()[0]), replacement.optimized.path.read_bytes()) + + def test_cancelled_bundle_creates_no_zip(self): + folder = self.root / "docs" + folder.mkdir() + zb.shutil.copyfile(self.source, folder / "source.pdf") + with self.assertRaises(zb.BundleCancelled): + zb.bundle_folder(folder, self.out, 0.0001, confirm_optimization=lambda r: None) + self.assertEqual(list(self.out.glob("*.zip")), []) + + def test_office_pdf_within_limit_is_automatic(self): + preview = Mock() + with patch.object(zb, "convert_office_to_pdf", return_value=self.report(kind="office")), \ + patch.object(zb, "compressed_zip_size", return_value=500): + report = zb.try_convert_office_for_limit(self.file, self.out, 1000, confirm_optimization=preview) + self.assertEqual(report.zip_size, 500) + preview.assert_not_called() + + def test_office_pdf_outside_limit_enters_pdf_workflow(self): + converted = self.report(kind="office") + preview = Mock() + with patch.object(zb, "convert_office_to_pdf", return_value=converted), \ + patch.object(zb, "compressed_zip_size", return_value=5000), \ + patch.object(zb, "choose_pdf_for_limit", return_value=(self.report(800), False)) as choose: + result = zb.try_convert_office_for_limit(self.file, self.out, 1000, confirm_optimization=preview) + self.assertEqual(result.zip_size, 800) + self.assertEqual(choose.call_args.args[0], converted.optimized) + self.assertIs(choose.call_args.kwargs["confirm_optimization"], preview) + + def test_large_image_warning_is_logged_but_hard_limit_remains(self): + path = self.root / "pixels.png" + zb.Image.new("RGB", (100, 100)).save(path) + log = Mock() + with patch.object(zb.Image, "MAX_IMAGE_PIXELS", 6000), warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with zb.open_image(path, log=log) as image: + self.assertEqual(image.size, (100, 100)) + self.assertEqual(caught, []) + log.assert_called_once() + with patch.object(zb.Image, "MAX_IMAGE_PIXELS", 4000): + with self.assertRaises(zb.Image.DecompressionBombError): + with zb.open_image(path): + pass + + def test_raster_page_dimensions_and_zoom(self): + report = zb.optimize_pdf(self.file, self.out, "test", 100, 54) + with fitz.open(report.optimized.path) as doc: + self.assertEqual(doc.page_count, 2) + self.assertEqual(doc[0].rect, fitz.Rect(0, 0, 300, 400)) + self.assertEqual(zb.render_pdf_preview(report.optimized.path, zoom=1).width, 400) + self.assertEqual(zb.render_pdf_preview(report.optimized.path, zoom=4).width, 1600) + + @unittest.skipUnless(zb.find_ghostscript(), "Ghostscript unavailable") + def test_real_ghostscript_preserves_text(self): + report = zb.pdf_at_dpi(self.file, self.out, 48, 30) + self.assertEqual(report.profile, "dpi_48") + with fitz.open(report.optimized.path) as doc: + self.assertIn("readable text", doc[0].get_text()) + self.assertEqual(doc.page_count, 2) + + def test_gui_zoom_navigation_retry_and_cancel(self): + app = zb.ctk.CTk() + dialog = None + try: + dialog = zb.OptimizationDialog(app, zb.replace(self.report(), can_retry=False)) + dialog.update() + dialog.set_zoom("400%") + self.assertEqual(dialog.original_image.width(), 1600) + self.assertEqual(dialog.optimized_image.width(), 1600) + dialog.scroll_previews("y", "moveto", 0.5) + self.assertGreater(dialog.canvases[0].yview()[0], 0) + self.assertEqual(dialog.canvases[0].yview(), dialog.canvases[1].yview()) + dialog.next_page() + self.assertEqual(dialog.page_label.cget("text"), "Pagina 2 / 2") + dialog.set_zoom("Adatta") + self.assertEqual(dialog.more_button.cget("state"), "disabled") + dialog.cancel() + self.assertIsNone(dialog.result) + dialog = None + finally: + if dialog is not None: + dialog.destroy() + self.close_test_app(app) + + +if __name__ == "__main__": + unittest.main() diff --git a/verify_exe.py b/verify_exe.py new file mode 100644 index 0000000..c92e408 --- /dev/null +++ b/verify_exe.py @@ -0,0 +1,86 @@ +"""Smoke-test the complete frozen package without external Python/Ghostscript on PATH.""" + +import io +import os +from pathlib import Path +import subprocess +import tempfile +import zipfile + +import fitz +from PIL import Image +from PyInstaller.archive.readers import CArchiveReader + + +def main(): + executable = Path(__file__).parent / "dist" / "ZipBundler.exe" + archive = CArchiveReader(str(executable)) + names = [name.replace("\\", "/").lower() for name in archive.toc] + for fragment in ("ghostscript/bin/gswin64c.exe", "ghostscript/bin/gsdll64.dll", + "python314.dll", "pythoncom314.dll", "pywintypes314.dll", + "customtkinter/assets/themes/blue.json", "assets/splash.png"): + if not any(fragment in name for name in names): + raise AssertionError(f"Missing bundled component: {fragment}") + print("Embedded components: OK") + + with tempfile.TemporaryDirectory(prefix="zipbundler-exe-test-") as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + output = root / "output" + noise = Image.effect_noise((1800, 2400), 80) + image_bytes = io.BytesIO() + noise.save(image_bytes, format="PNG") + pdf = source / "native-scan.pdf" + with fitz.open() as document: + page = document.new_page(width=600, height=800) + page.insert_image(page.rect, stream=image_bytes.getvalue()) + page.insert_text((20, 30), ("Searchable native text for bundled engine verification.\n" * 20), fontsize=8) + document.save(pdf) + noise.save(source / "photo.jpg", quality=98) + + environment = os.environ.copy() + windows = Path(environment.get("SystemRoot", r"C:\Windows")) + environment["PATH"] = os.pathsep.join((str(windows / "System32"), str(windows))) + for key in ("PYTHONPATH", "PYTHONHOME", "GS_LIB", "GS_FONTPATH"): + environment.pop(key, None) + startup = subprocess.STARTUPINFO() + startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startup.wShowWindow = 0 + completed = subprocess.run( + [str(executable), str(source), "--output", str(output), "--size-mb", "0.6"], + cwd=root, env=environment, startupinfo=startup, timeout=60, + ) + if completed.returncode: + raise AssertionError(f"EXE exited with {completed.returncode}") + archives = sorted(output.glob("pacchetto_*.zip")) + if not archives: + raise AssertionError("No ZIP packages were produced") + found_pdf = found_image = False + for path in archives: + if path.stat().st_size > int(0.6 * 1024 * 1024): + raise AssertionError(f"Oversized archive: {path}") + with zipfile.ZipFile(path) as zipped: + if zipped.testzip() is not None: + raise AssertionError("Corrupt ZIP member") + for name in zipped.namelist(): + data = zipped.read(name) + if name.endswith(".pdf"): + with fitz.open(stream=data, filetype="pdf") as document: + if "Searchable native text" not in document[0].get_text(): + raise AssertionError("Native PDF text was lost") + found_pdf = True + elif name.endswith(".jpg"): + with Image.open(io.BytesIO(data)) as image: + image.load() + if image.format != "JPEG": + raise AssertionError("Image format changed") + found_image = True + print(f"{path.name}: {path.stat().st_size} bytes, OK") + if not (found_pdf and found_image): + raise AssertionError("Missing optimized PDF or JPEG") + print("Frozen EXE: PDF with searchable text and JPEG compression OK; external tools absent from PATH") + + +if __name__ == "__main__": + main() diff --git a/zipbundler.py b/zipbundler.py index 5acbe46..53573ba 100644 --- a/zipbundler.py +++ b/zipbundler.py @@ -5,10 +5,12 @@ import subprocess import sys import tempfile import threading +import warnings import zipfile -from dataclasses import dataclass +from contextlib import contextmanager +from dataclasses import dataclass, replace from pathlib import Path -from tkinter import filedialog, messagebox +from tkinter import Canvas, filedialog, messagebox import customtkinter as ctk import fitz @@ -24,6 +26,8 @@ PDF_OPTIMIZE_PROFILES = ( ("piu_leggero", 120, 60), ("minimo", 100, 54), ) +PDF_AGGRESSIVE_PROFILES = ((72, 45), (60, 38), (48, 30), (36, 24), (24, 18)) +IMAGE_AGGRESSIVE_PROFILES = ((1000, 50), (800, 40), (600, 30), (400, 24), (200, 18), (100, 12)) IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"} OFFICE_SUFFIXES = {".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"} IMAGE_OPTIMIZE_PROFILES = ( @@ -57,6 +61,14 @@ class OptimizedPdf: profile: str dpi: int | str | None quality: int | None + zip_size: int = 0 + max_zip_size: int = 0 + can_retry: bool = True + next_step: str = "" + + +class BundleCancelled(Exception): + pass def mb_to_bytes(size_mb): @@ -68,6 +80,8 @@ def format_mb(size): def optimization_label(report): + if report.profile == "originale": + return "originale: nessuna riduzione utile" if report.kind == "office": return "conversione PDF" if report.kind == "image": @@ -76,7 +90,7 @@ def optimization_label(report): "qualita_email": "qualita email", "massima_compressione": "massima compressione", } - return labels.get(report.profile, report.profile) + return labels.get(report.profile, f"lato massimo {report.dpi} px, qualita {report.quality}") if report.profile.startswith("ghostscript_"): return f"Ghostscript {report.dpi}" if report.dpi is None: @@ -191,7 +205,7 @@ def optimized_pdf_result(source_file, optimized_path, profile, dpi, quality): ) -def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None): +def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None, dpi=None, quality=None): ghostscript = find_ghostscript() if not ghostscript: if log: @@ -204,7 +218,7 @@ def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None) if log: log( - f"Provo Ghostscript /{profile}: " + f"Provo Ghostscript {str(dpi) + ' DPI' if dpi else '/' + profile}: " f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})" ) @@ -220,14 +234,36 @@ def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None) "-dCompressFonts=true", "-dSubsetFonts=true", f"-sOutputFile={optimized_path}", - str(source_file.path), ] + if dpi is not None: + for kind in ("Color", "Gray", "Mono"): + command.extend([ + f"-dDownsample{kind}Images=true", + f"-d{kind}ImageResolution={dpi}", + f"-d{kind}ImageDownsampleThreshold=1.0", + ]) + command.extend(["-dPassThroughJPEGImages=false", "-dPassThroughJPXImages=false"]) + for kind in ("Color", "Gray"): + command.extend([ + f"-d{kind}ImageDownsampleType=/Bicubic", + f"-dAutoFilter{kind}Images=false", + f"-d{kind}ImageFilter=/DCTEncode", + ]) + qfactor = 1 - (quality or 75) / 100 + command.extend([ + "-c", + f"<< /ColorImageDict << /QFactor {qfactor:.2f} >> " + f"/GrayImageDict << /QFactor {qfactor:.2f} >> >> setdistillerparams", + "-f", + ]) + command.append(str(source_file.path)) completed = subprocess.run( command, capture_output=True, text=True, timeout=300, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) if completed.returncode != 0 or not optimized_path.exists(): @@ -240,9 +276,9 @@ def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None) return optimized_pdf_result( source_file, optimized_path, - profile=f"ghostscript_{profile}", - dpi=profile, - quality=None, + profile=f"dpi_{dpi}" if dpi else f"ghostscript_{profile}", + dpi=dpi if dpi else profile, + quality=quality, ) @@ -302,7 +338,7 @@ def optimize_pdf(source_file, output_folder, profile, dpi, quality, log=None): pixmap = source_page.get_pixmap(matrix=matrix, alpha=False) image_bytes = pixmap.tobytes("jpeg", jpg_quality=quality) - page = optimized_doc.new_page(width=pixmap.width, height=pixmap.height) + page = optimized_doc.new_page(width=source_page.rect.width, height=source_page.rect.height) page.insert_image(page.rect, stream=image_bytes) optimized_doc.save( @@ -425,6 +461,21 @@ def image_target_format(source_path, convert_uncompressed=False): return None +@contextmanager +def open_image(path, log=None): + # Keep Pillow's hard pixel limit; route its advisory warning to the application log. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", Image.DecompressionBombWarning) + image = Image.open(path) + try: + if Image.MAX_IMAGE_PIXELS and image.width * image.height > Image.MAX_IMAGE_PIXELS and log: + log(f"Immagine molto grande ({image.width} x {image.height} pixel): " + "la riduzione puo richiedere piu memoria e tempo.") + yield image + finally: + image.close() + + def optimize_image(source_file, output_folder, profile, max_side, quality, log=None, convert_uncompressed=False): optimized_root = Path(output_folder) / "_immagini_ridotte_email" target_format = image_target_format(source_file.path, convert_uncompressed=convert_uncompressed) @@ -445,7 +496,9 @@ def optimize_image(source_file, output_folder, profile, max_side, quality, log=N f"({format_mb(source_file.stat().st_size)})" ) - with Image.open(source_file.path) as image: + with open_image(source_file.path, log=log) as image: + if image.format == "JPEG": + image.draft(image.mode, (max_side, max_side)) image = ImageOps.exif_transpose(image) if getattr(image, "is_animated", False) and log: @@ -465,42 +518,14 @@ def optimize_image(source_file, output_folder, profile, max_side, quality, log=N def try_optimize_image_for_limit(source_file, output_folder, max_zip_size, log=None, convert_uncompressed=False): - original_size = source_file.stat().st_size - - for profile_name, max_side, quality in IMAGE_OPTIMIZE_PROFILES: - optimized = optimize_image( - source_file, - output_folder, - profile_name, - max_side, - quality, - log=log, + attempts = [ + lambda p=p, side=side, q=q: optimize_image( + source_file, output_folder, p, side, q, log=log, convert_uncompressed=convert_uncompressed, ) - - if optimized.optimized_size >= original_size: - if log: - log( - "Scarto questa copia: non riduce l'immagine " - f"({format_mb(original_size)} -> {format_mb(optimized.optimized_size)})." - ) - optimized.optimized.path.unlink(missing_ok=True) - continue - - with tempfile.TemporaryDirectory() as temp_folder: - zip_size = compressed_zip_size([optimized.optimized], temp_folder) - - if zip_size <= max_zip_size: - return optimized - - if log: - log( - f"La copia immagine e ancora troppo grande: " - f"{format_mb(zip_size)} nello ZIP." - ) - optimized.optimized.path.unlink(missing_ok=True) - - return None + for p, side, q in IMAGE_OPTIMIZE_PROFILES + ] + return select_standard_result(source_file, "image", attempts, max_zip_size, log) def optimized_office_result(source_file, optimized_path): @@ -604,7 +629,7 @@ def convert_office_to_pdf(source_file, output_folder, log=None): return optimized_office_result(source_file, pdf_path) -def try_convert_office_for_limit(source_file, output_folder, max_zip_size, log=None): +def try_convert_office_for_limit(source_file, output_folder, max_zip_size, log=None, confirm_optimization=None): converted = convert_office_to_pdf(source_file, output_folder, log=log) if converted is None: return None @@ -613,19 +638,55 @@ def try_convert_office_for_limit(source_file, output_folder, max_zip_size, log=N zip_size = compressed_zip_size([converted.optimized], temp_folder) if zip_size <= max_zip_size: - return converted + return replace(converted, zip_size=zip_size, max_zip_size=max_zip_size) - if log: - log(f"Il PDF convertito e ancora troppo grande: {format_mb(zip_size)} nello ZIP.") + report, _ = choose_pdf_for_limit( + converted.optimized, output_folder, max_zip_size, log=log, + confirm_optimization=confirm_optimization, + ) + return report - converted.optimized.path.unlink(missing_ok=True) - return None + +def measured_report(report, max_zip_size): + with tempfile.TemporaryDirectory() as temp_folder: + size = compressed_zip_size([report.optimized], temp_folder) + return replace(report, zip_size=size, max_zip_size=max_zip_size) + + +def select_standard_result(source_file, kind, attempts, max_zip_size, log=None): + size = source_file.stat().st_size + best = measured_report( + OptimizedPdf(source_file, source_file, size, size, kind, "originale", None, None), max_zip_size, + ) + # Profiles write to the same destination. Preserve the best bytes, not just its path. + with tempfile.TemporaryDirectory() as temp_folder: + saved = Path(temp_folder) / "best" + for attempt in attempts: + try: + candidate = attempt() + if candidate is None: + continue + candidate = measured_report(candidate, max_zip_size) + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc: + if log: + log(f"Profilo standard non riuscito: {exc}") + continue + if log: + log(f"{optimization_label(candidate)}: {format_mb(candidate.zip_size)} nello ZIP; " + f"limite {format_mb(max_zip_size)}.") + if candidate.zip_size <= max_zip_size: + return candidate + if candidate.zip_size < best.zip_size: + best = candidate + shutil.copyfile(candidate.optimized.path, saved) + if best.optimized.path != source_file.path: + shutil.copyfile(saved, best.optimized.path) + return best def try_optimize_pdf_for_limit(source_file, output_folder, max_zip_size, log=None): - original_size = source_file.stat().st_size skip_raster_profiles = pdf_has_significant_text(source_file.path) - + attempts = [] for profile_name, dpi, quality in PDF_OPTIMIZE_PROFILES: if isinstance(dpi, int) and skip_raster_profiles: if log: @@ -635,38 +696,89 @@ def try_optimize_pdf_for_limit(source_file, output_folder, max_zip_size, log=Non ) break - if log: - log(f"Provo profilo PDF '{profile_name}'.") - if isinstance(dpi, str): - optimized = optimize_pdf_with_ghostscript(source_file, output_folder, dpi, log=log) - if optimized is None: - continue + attempts.append(lambda dpi=dpi: optimize_pdf_with_ghostscript( + source_file, output_folder, dpi, log=log, + )) else: - optimized = optimize_pdf(source_file, output_folder, profile_name, dpi, quality, log=log) + attempts.append(lambda p=profile_name, dpi=dpi, q=quality: optimize_pdf( + source_file, output_folder, p, dpi, q, log=log, + )) + return select_standard_result(source_file, "pdf", attempts, max_zip_size, log) - if optimized.optimized_size >= original_size: - if log: - log( - "Scarto questa copia: non riduce il PDF " - f"({format_mb(original_size)} -> {format_mb(optimized.optimized_size)})." - ) - optimized.optimized.path.unlink(missing_ok=True) - continue - with tempfile.TemporaryDirectory() as temp_folder: - zip_size = compressed_zip_size([optimized.optimized], temp_folder) - - if zip_size <= max_zip_size: - return optimized - - if log: - log( - f"La copia ridotta e ancora troppo grande: " - f"{format_mb(zip_size)} nello ZIP." +def pdf_at_dpi(source_file, output_folder, dpi, quality, log=None, raster=False): + if not raster: + try: + report = optimize_pdf_with_ghostscript( + source_file, output_folder, "default", log=log, dpi=dpi, quality=quality, ) + if report is not None: + return report + except (OSError, subprocess.SubprocessError) as exc: + if log: + log(f"Ghostscript a {dpi} DPI non riuscito: {exc}") + if log: + log(f"Ricodifica delle pagine a immagini: {dpi} DPI, qualita JPEG {quality}.") + return optimize_pdf(source_file, output_folder, f"raster_{dpi}", dpi, quality, log=log) - return None + +def negotiate_compression(report, attempts, max_zip_size, confirm_optimization, log=None): + if report.zip_size <= max_zip_size: + if log: + log("Profilo standard entro il limite: copia accettata automaticamente.") + return report + if confirm_optimization is None: + raise ValueError("Il file resta fuori limite: avvia la GUI per scegliere una compressione aggiuntiva.") + for index in range(len(attempts) + 1): + next_step = attempts[index][0] if index < len(attempts) else "" + report = replace(report, can_retry=index < len(attempts), next_step=next_step) + answer = confirm_optimization(report) + if answer is True: + return report + if answer is None: + raise BundleCancelled("Elaborazione annullata: nessun risultato rifiutato e stato impacchettato.") + if not report.can_retry: + raise BundleCancelled("Profili esauriti: risultato non accettato, elaborazione annullata.") + try: + candidate = attempts[index][1]() + report = measured_report(candidate, max_zip_size) + if log: + log(f"Compressione aggiuntiva: {optimization_label(report)}, " + f"{format_mb(report.zip_size)} nello ZIP. Richiesta approvazione.") + except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc: + # Do not leave a report pointing at an overwritten or partial candidate. + raise ValueError(f"Compressione aggiuntiva non riuscita; nessun pacchetto creato: {exc}") from exc + raise BundleCancelled("Nessuna compressione accettata.") + + +def choose_pdf_for_limit(source_file, output_folder, max_zip_size, log=None, confirm_optimization=None): + report = try_optimize_pdf_for_limit(source_file, output_folder, max_zip_size, log=log) + attempts = [] + for raster in ((False, True) if find_ghostscript() else (True,)): + for dpi, quality in PDF_AGGRESSIVE_PROFILES: + description = (f"Pagine a immagini, {dpi} DPI: il testo non sara selezionabile." + if raster else f"Ghostscript, immagini a {dpi} DPI.") + attempts.append((description, lambda dpi=dpi, q=quality, raster=raster: pdf_at_dpi( + source_file, output_folder, dpi, q, log=log, raster=raster, + ))) + report = negotiate_compression(report, attempts, max_zip_size, confirm_optimization, log) + return report, report.zip_size > max_zip_size + + +def choose_image_for_limit(source_file, output_folder, max_zip_size, log=None, + convert_uncompressed=False, confirm_optimization=None): + report = try_optimize_image_for_limit( + source_file, output_folder, max_zip_size, log=log, convert_uncompressed=convert_uncompressed, + ) + attempts = [ + (f"Lato massimo {side} pixel, qualita {quality}.", + lambda side=side, q=quality: optimize_image( + source_file, output_folder, f"aggressivo_{side}", side, q, log=log, + convert_uncompressed=convert_uncompressed, + )) for side, quality in IMAGE_AGGRESSIVE_PROFILES + ] + return negotiate_compression(report, attempts, max_zip_size, confirm_optimization, log) def split_into_groups( @@ -706,20 +818,22 @@ def split_into_groups( replacement = None if file_path.path.suffix.lower() == ".pdf": - replacement = try_optimize_pdf_for_limit( + replacement, _ = choose_pdf_for_limit( file_path, output_folder, max_zip_size, log=log, + confirm_optimization=confirm_optimization, ) elif file_path.path.suffix.lower() in IMAGE_SUFFIXES: - replacement = try_optimize_image_for_limit( + replacement = choose_image_for_limit( file_path, output_folder, max_zip_size, log=log, convert_uncompressed=convert_uncompressed_images, + confirm_optimization=confirm_optimization, ) elif convert_office_to_pdf_enabled and file_path.path.suffix.lower() in OFFICE_SUFFIXES: replacement = try_convert_office_for_limit( @@ -727,17 +841,20 @@ def split_into_groups( output_folder, max_zip_size, log=log, + confirm_optimization=confirm_optimization, ) - if replacement and replacement.kind != "office" and confirm_optimization: - accepted = confirm_optimization(replacement) - if not accepted: - if log: - log(f"Ottimizzazione rifiutata: {file_path.arcname}") - replacement = None - if replacement: optimized_reports.append(replacement) + if replacement.zip_size > max_zip_size: + if current_group: + groups.append(current_group) + current_group = [] + groups.append([replacement.optimized]) + if log: + log(f"Pacchetto separato FUORI LIMITE, accettato esplicitamente: " + f"{file_path.arcname}, {format_mb(replacement.zip_size)} nello ZIP.") + continue replacement_group = current_group + [replacement.optimized] replacement_group_size = compressed_zip_size(replacement_group, temp_folder) @@ -762,16 +879,11 @@ def split_into_groups( f"{format_mb(replacement.optimized_size)})" ) else: - if current_group: - groups.append(current_group) - current_group = [] - if log: - log( - "ATTENZIONE: il file supera il limite anche da solo " - f"e verra inserito in un pacchetto fuori limite: {file_path.arcname} " - f"({format_mb(single_size)} nello ZIP)" - ) - groups.append([file_path]) + raise ValueError( + f"{file_path.arcname}: {format_mb(single_size)} nello ZIP, oltre il limite. " + "Nessuna conversione disponibile o abilitata per questo file; " + "elaborazione interrotta senza creare pacchetti fuori limite non approvati." + ) if current_group: groups.append(current_group) @@ -899,21 +1011,25 @@ def pdf_page_count(pdf_path): doc.close() -def render_pdf_preview(pdf_path, page_index=0, max_size=(360, 480)): +def render_pdf_preview(pdf_path, page_index=0, max_size=(360, 480), zoom=None): doc = fitz.open(pdf_path) try: page_index = max(0, min(page_index, doc.page_count - 1)) page = doc.load_page(page_index) - pixmap = page.get_pixmap(matrix=fitz.Matrix(1.2, 1.2), alpha=False) + scale = (96 / 72 * zoom) if zoom is not None else min( + max_size[0] / page.rect.width, max_size[1] / page.rect.height, + ) + pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False) image = Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples) - image.thumbnail(max_size, Image.Resampling.LANCZOS) return image finally: doc.close() def render_image_preview(image_path, max_size=(360, 480)): - with Image.open(image_path) as image: + with open_image(image_path) as image: + if image.format == "JPEG": + image.draft(image.mode, max_size) image = ImageOps.exif_transpose(image) if getattr(image, "is_animated", False): image.seek(0) @@ -922,9 +1038,9 @@ def render_image_preview(image_path, max_size=(360, 480)): return image.copy() -def render_preview(report, path, page_index=0): +def render_preview(report, path, page_index=0, zoom=None, max_size=(360, 480)): if report.kind == "pdf": - return render_pdf_preview(path, page_index) + return render_pdf_preview(path, page_index, max_size=max_size, zoom=zoom) return render_image_preview(path) @@ -932,9 +1048,11 @@ class OptimizationDialog(ctk.CTkToplevel): def __init__(self, parent, report): super().__init__(parent) self.report = report - self.result = False + self.result = None self.page_index = 0 self.page_count = 1 + self.zoom = None + self.canvases = [] if report.kind == "pdf": self.page_count = min( pdf_page_count(report.original.path), @@ -949,14 +1067,18 @@ class OptimizationDialog(ctk.CTkToplevel): self.grid_columnconfigure((0, 1), weight=1) self.grid_rowconfigure(2, weight=1) - item_label = "PDF" if report.kind == "pdf" else "immagine" - title = ( - f"{report.original.arcname.name} supera il limite. " - "Ho creato una copia alleggerita per email." - ) - ctk.CTkLabel(self, text=title, wraplength=780, font=ctk.CTkFont(size=16, weight="bold")).grid( + oversized = report.zip_size > report.max_zip_size + status = "FUORI LIMITE" if oversized else "ENTRO IL LIMITE - qualita da approvare" + title = (f"{report.original.arcname.name}\n{status}: ZIP {format_mb(report.zip_size)}; " + f"limite {format_mb(report.max_zip_size)}.\n") + title += (f"Prossimo tentativo: {report.next_step}" if report.can_retry else + "Ultimo profilo disponibile. Accetta questo risultato oppure annulla l'elaborazione.") + self.title_label = ctk.CTkLabel(self, text=title, wraplength=720, font=ctk.CTkFont(size=16, weight="bold")) + self.title_label.grid( row=0, column=0, columnspan=2, padx=18, pady=(18, 10), sticky="w" ) + self.bind("", lambda event: self.title_label.configure( + wraplength=max(300, self.winfo_width() - 50)) if event.widget == self else None) pager = ctk.CTkFrame(self, fg_color="transparent") pager.grid(row=1, column=0, columnspan=2, padx=18, pady=(0, 8), sticky="ew") @@ -973,6 +1095,13 @@ class OptimizationDialog(ctk.CTkToplevel): if report.kind != "pdf": self.prev_button.grid_remove() self.next_button.grid_remove() + else: + self.zoom_menu = ctk.CTkOptionMenu( + pager, values=["Adatta", "50%", "100%", "150%", "200%", "300%", "400%"], + width=100, command=self.set_zoom, + ) + self.zoom_menu.set("Adatta") + self.zoom_menu.grid(row=0, column=3, padx=(16, 0)) self.original_image = None self.optimized_image = None @@ -992,14 +1121,20 @@ class OptimizationDialog(ctk.CTkToplevel): buttons = ctk.CTkFrame(self, fg_color="transparent") buttons.grid(row=3, column=0, columnspan=2, padx=18, pady=(10, 18), sticky="e") - ctk.CTkButton(buttons, text="Rifiuta", width=110, command=self.reject).grid( + ctk.CTkButton(buttons, text="Annulla elaborazione", width=160, command=self.cancel).grid( row=0, column=0, padx=(0, 10) ) - ctk.CTkButton(buttons, text="Accetta copia ridotta", width=180, command=self.accept).grid( - row=0, column=1 + self.more_button = ctk.CTkButton( + buttons, text="Rifiuta e comprimi di piu", width=190, command=self.reject, + state="normal" if report.can_retry else "disabled", + ) + self.more_button.grid(row=0, column=1, padx=(0, 10)) + ctk.CTkButton(buttons, text="Accetta fuori limite" if oversized else "Accetta risultato", + width=180, command=self.accept).grid( + row=0, column=2 ) - self.protocol("WM_DELETE_WINDOW", self.reject) + self.protocol("WM_DELETE_WINDOW", self.cancel) def _build_preview_column(self, column, label, size_text): frame = ctk.CTkFrame(self, corner_radius=8) @@ -1010,23 +1145,48 @@ class OptimizationDialog(ctk.CTkToplevel): ctk.CTkLabel(frame, text=f"{label} - {size_text}", font=ctk.CTkFont(weight="bold")).grid( row=0, column=0, padx=12, pady=(12, 8), sticky="w" ) - image_label = ctk.CTkLabel(frame, text="") - image_label.grid(row=1, column=0, padx=12, pady=(0, 12), sticky="n") + canvas = Canvas(frame, background="#333333", highlightthickness=0, width=360, height=420) + canvas.grid(row=1, column=0, padx=(8, 0), sticky="nsew") + vertical = ctk.CTkScrollbar(frame, command=lambda *args: self.scroll_previews("y", *args)) + vertical.grid(row=1, column=1, sticky="ns") + horizontal = ctk.CTkScrollbar( + frame, orientation="horizontal", command=lambda *args: self.scroll_previews("x", *args), + ) + horizontal.grid(row=2, column=0, sticky="ew", padx=8) + canvas.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set) + canvas.bind("", self.scroll_wheel) + canvas.bind("", lambda event: self.scroll_wheel(event, "x")) + self.canvases.append(canvas) - if column == 0: - self.original_image_label = image_label - else: - self.optimized_image_label = image_label + def scroll_previews(self, axis, *args): + for canvas in self.canvases: + getattr(canvas, axis + "view")(*args) + + def scroll_wheel(self, event, axis="y"): + self.scroll_previews(axis, "scroll", -1 if event.delta > 0 else 1, "units") + return "break" + + def set_zoom(self, value): + self.zoom = None if value == "Adatta" else int(value.rstrip("%")) / 100 + self.update_previews() def update_previews(self): + self.update_idletasks() + max_size = (max(100, self.canvases[0].winfo_width() - 8), + max(100, self.canvases[0].winfo_height() - 8)) self.original_image = ImageTk.PhotoImage( - render_preview(self.report, self.report.original.path, self.page_index) + render_preview(self.report, self.report.original.path, self.page_index, self.zoom, max_size) ) self.optimized_image = ImageTk.PhotoImage( - render_preview(self.report, self.report.optimized.path, self.page_index) + render_preview(self.report, self.report.optimized.path, self.page_index, self.zoom, max_size) ) - self.original_image_label.configure(image=self.original_image) - self.optimized_image_label.configure(image=self.optimized_image) + for canvas, photo in zip(self.canvases, (self.original_image, self.optimized_image)): + canvas.delete("all") + x = max(0, (canvas.winfo_width() - photo.width()) // 2) + canvas.create_image(x, 0, image=photo, anchor="nw") + canvas.configure(scrollregion=(0, 0, max(photo.width(), canvas.winfo_width()), photo.height())) + canvas.xview_moveto(0) + canvas.yview_moveto(0) if self.report.kind == "pdf": self.page_label.configure(text=f"Pagina {self.page_index + 1} / {self.page_count}") else: @@ -1054,6 +1214,10 @@ class OptimizationDialog(ctk.CTkToplevel): self.result = False self.destroy() + def cancel(self): + self.result = None + self.destroy() + class LargeEmailSizeDialog(ctk.CTkToplevel): def __init__(self, parent, size_value): @@ -1349,17 +1513,26 @@ class ZipBundlerApp(ctk.CTk): self.large_size_panel.grid_remove() def confirm_optimization(self, report): + return self.ask_worker_dialog(lambda: OptimizationDialog(self, report)) + + def ask_worker_dialog(self, factory): event = threading.Event() answer = {"value": False} def show_dialog(): - dialog = OptimizationDialog(self, report) - self.wait_window(dialog) - answer["value"] = dialog.result - event.set() + try: + dialog = factory() + self.wait_window(dialog) + answer["value"] = dialog.result + except Exception as exc: + answer["error"] = exc + finally: + event.set() self.after(0, show_dialog) event.wait() + if "error" in answer: + raise answer["error"] return answer["value"] def confirm_large_email_size(self, size_value): @@ -1431,6 +1604,8 @@ class ZipBundlerApp(ctk.CTk): convert_uncompressed_images=convert_uncompressed, convert_office_to_pdf_enabled=convert_office, ) + except BundleCancelled as exc: + self.after(0, self.append_log, str(exc)) except Exception as exc: self.after(0, messagebox.showerror, "Errore", str(exc)) self.after(0, self.append_log, f"ERRORE: {exc}")