Release ZipBundler 1.0.0 with guided recompression and complete Windows build
This commit is contained in:
159
README.md
159
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.
|
||||
|
||||
54
RELEASE_NOTES.md
Normal file
54
RELEASE_NOTES.md
Normal 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
|
||||
```
|
||||
@@ -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) {
|
||||
|
||||
239
test_pdf_workflow.py
Normal file
239
test_pdf_workflow.py
Normal 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
86
verify_exe.py
Normal 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()
|
||||
433
zipbundler.py
433
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("<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)
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user