Release ZipBundler 1.0.0 with guided recompression and complete Windows build

This commit is contained in:
2026-09-17 16:22:18 +02:00
parent ca934fe4b9
commit 66e14f72c3
6 changed files with 851 additions and 155 deletions

159
README.md
View File

@@ -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.

54
RELEASE_NOTES.md Normal file
View File

@@ -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
```

View File

@@ -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
if (-not $SkipDependencyInstall) {
python -m pip install -r requirements.txt
python -m pip install --upgrade pyinstaller
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) {

239
test_pdf_workflow.py Normal file
View File

@@ -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()

86
verify_exe.py Normal file
View File

@@ -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()

View File

@@ -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)
report, _ = choose_pdf_for_limit(
converted.optimized, output_folder, max_zip_size, log=log,
confirm_optimization=confirm_optimization,
)
return report
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"Il PDF convertito e ancora troppo grande: {format_mb(zip_size)} nello ZIP.")
converted.optimized.path.unlink(missing_ok=True)
return None
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)})."
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,
)
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 report is not None:
return report
except (OSError, subprocess.SubprocessError) as exc:
if log:
log(
f"La copia ridotta e ancora troppo grande: "
f"{format_mb(zip_size)} nello ZIP."
)
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)"
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."
)
groups.append([file_path])
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("<Configure>", 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("<MouseWheel>", self.scroll_wheel)
canvas.bind("<Shift-MouseWheel>", 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)
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}")