From a9604023b33d17574a965a5e11aab33f98884e71 Mon Sep 17 00:00:00 2001 From: allebonvi Date: Thu, 2 Jul 2026 19:57:28 +0200 Subject: [PATCH] Initial ZipBundler 1.0 --- .gitignore | 19 + README.md | 149 +++++ requirements.txt | 4 + zipbundler.py | 1483 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1655 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 zipbundler.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a3585e --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +__pycache__/ +*.py[cod] + +# Local test/source data and generated packages +SDS/ +SDS_*/ +*_zip_*/ +*_zip_*mb/ +*_zipped/ + +# Generated optimized copies inside output folders +_pdf_ridotti_email/ +_immagini_ridotte_email/ +_office_convertiti_pdf/ + +# Local build outputs +build/ +dist/ +*.spec diff --git a/README.md b/README.md new file mode 100644 index 0000000..f607072 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# ZipBundler versione 1.0 + +ZipBundler e un piccolo tool desktop per creare piu archivi ZIP indipendenti a partire da una cartella sorgente, mantenendo ogni pacchetto entro una dimensione massima configurabile. + +L'obiettivo pratico e preparare allegati gestibili per invio tramite posta elettronica aziendale, dove spesso il limite reale degli allegati e vicino a 20-25 MB. + +## Funzioni principali + +- Scelta della cartella sorgente. +- Scelta della cartella di destinazione. +- Dimensione massima dei pacchetti tramite spinbox. +- Default a 15 MB, piu prudente per l'invio email. +- Avviso inline sopra 18 MB, con richiesta di accettazione esplicita. +- Creazione di pacchetti `pacchetto_001.zip`, `pacchetto_002.zip`, ecc. +- Verifica della dimensione reale dello ZIP finale, non della somma dei file originali. +- Ottimizzazione guidata di PDF troppo grandi. +- Ottimizzazione guidata di immagini troppo grandi. +- Opzione esplicita per convertire formati immagine non compressi in JPEG. +- Opzione esplicita per convertire file Office grandi in PDF. + +## Scelta fondamentale sulla dimensione + +La dimensione limite viene controllata sul file `.zip` finale. + +Questo e importante perche la dimensione dei file originali non coincide con la dimensione dell'archivio compresso. Il programma crea ZIP temporanei di prova, misura il risultato reale e solo dopo decide se un file puo entrare nel pacchetto corrente. + +## PDF troppo grandi + +Quando un PDF supera da solo il limite impostato, ZipBundler prova diversi approcci: + +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. + +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. + +## Ghostscript + +Ghostscript e usato come motore preferenziale per ridurre PDF nativi quando e disponibile. + +Il programma cerca: + +- `gswin64c` +- `gswin32c` +- `gs` + +Se Ghostscript non e presente, il programma salta quel passaggio e continua con le altre strategie. + +## Immagini troppo grandi + +Per le immagini viene usata Pillow. + +Formati supportati: + +- JPG/JPEG +- PNG +- TIFF/TIF +- BMP +- WEBP + +Profili: + +- qualita alta: lato lungo massimo 2500 px +- qualita email: lato lungo massimo 1800 px +- massima compressione: lato lungo massimo 1200 px + +Per impostazione predefinita il formato originale viene mantenuto. Per esempio un TIFF ridotto resta TIFF. + +Esiste pero una checkbox esplicita: + +`Converti formati non compressi in JPEG (es. BMP/TIFF)` + +Solo se questa opzione e attiva, BMP e TIFF vengono convertiti in JPEG per ottenere file molto piu leggeri. + +Le copie ridotte vengono salvate in `_immagini_ridotte_email`. + +## File Office troppo grandi + +ZipBundler puo convertire file Office grandi in PDF, ma solo se la checkbox dedicata e attiva: + +`Converti file Office grandi in PDF (es. DOCX -> PDF)` + +Formati previsti: + +- DOC/DOCX +- XLS/XLSX +- PPT/PPTX + +La conversione usa Microsoft Office tramite automazione COM (`win32com`). Quindi richiede Office installato e funzionante sul PC. + +Le copie PDF vengono salvate in `_office_convertiti_pdf`. + +LibreOffice headless e stato valutato come opzione futura, ma non e incluso per evitare di aumentare molto il peso di un eventuale eseguibile PyInstaller. + +## 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. +- 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. + +## Uso GUI + +```powershell +python .\zipbundler.py +``` + +## Uso CLI + +```powershell +python .\zipbundler.py C:\percorso\cartella --output C:\percorso\output --size-mb 15 +``` + +Opzioni: + +```powershell +--convert-uncompressed-images +--convert-office-to-pdf +``` + +## Dipendenze Python + +```powershell +pip install -r requirements.txt +``` + +Dipendenze principali: + +- CustomTkinter +- Pillow +- PyMuPDF + +Per Office COM serve anche `pywin32`, installato nell'ambiente in cui viene eseguito il programma. + +## Packaging futuro + +Il progetto e pensato per essere trasformato in un eseguibile Windows con PyInstaller. + +Da valutare: + +- inclusione o rilevamento esterno di Ghostscript; +- gestione piu esplicita di `pywin32`; +- eventuale icona applicativa; +- firma dell'eseguibile, se necessaria in ambiente aziendale. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..16b01e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +customtkinter>=5.2.2 +Pillow>=12.2.0 +PyMuPDF>=1.28.0 +pywin32>=306; platform_system == "Windows" diff --git a/zipbundler.py b/zipbundler.py new file mode 100644 index 0000000..08d6717 --- /dev/null +++ b/zipbundler.py @@ -0,0 +1,1483 @@ +import argparse +import hashlib +import shutil +import subprocess +import sys +import tempfile +import threading +import zipfile +from dataclasses import dataclass +from pathlib import Path +from tkinter import filedialog, messagebox + +import customtkinter as ctk +import fitz +from PIL import Image, ImageOps, ImageTk + + +DEFAULT_MAX_ZIP_SIZE_MB = 15 +PDF_OPTIMIZE_PROFILES = ( + ("ottimizzazione_senza_perdita", None, None), + ("ghostscript_ebook", "ebook", None), + ("ghostscript_screen", "screen", None), + ("qualita_email", 150, 68), + ("piu_leggero", 120, 60), + ("minimo", 100, 54), +) +IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"} +OFFICE_SUFFIXES = {".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"} +IMAGE_OPTIMIZE_PROFILES = ( + ("qualita_alta", 2500, 85), + ("qualita_email", 1800, 75), + ("massima_compressione", 1200, 60), +) + + +@dataclass(frozen=True) +class BundleFile: + path: Path + arcname: Path + original_path: Path | None = None + + @property + def display_name(self): + return self.arcname.name + + def stat(self): + return self.path.stat() + + +@dataclass(frozen=True) +class OptimizedPdf: + original: BundleFile + optimized: BundleFile + original_size: int + optimized_size: int + kind: str + profile: str + dpi: int | str | None + quality: int | None + + +def mb_to_bytes(size_mb): + return int(float(size_mb) * 1024 * 1024) + + +def format_mb(size): + return f"{size / 1024 / 1024:.2f} MB" + + +def optimization_label(report): + if report.kind == "office": + return "conversione PDF" + if report.kind == "image": + labels = { + "qualita_alta": "qualita alta", + "qualita_email": "qualita email", + "massima_compressione": "massima compressione", + } + return labels.get(report.profile, report.profile) + if report.profile.startswith("ghostscript_"): + return f"Ghostscript {report.dpi}" + if report.dpi is None: + return "senza perdita" + return f"{report.dpi} DPI" + + +def collect_files(folder, exclude_folder=None): + files = [] + folder = Path(folder).resolve() + exclude_folder = Path(exclude_folder).resolve() if exclude_folder else None + + for path in folder.rglob("*"): + if not path.is_file(): + continue + + resolved = path.resolve() + if exclude_folder and resolved.is_relative_to(exclude_folder): + continue + + files.append(BundleFile(path=resolved, arcname=resolved.relative_to(folder))) + + return files + + +def create_zip(zip_path, files): + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for bundle_file in files: + zf.write(bundle_file.path, bundle_file.arcname) + + +def compressed_zip_size(files, temp_folder): + temp_zip = Path(temp_folder) / "test.zip" + create_zip(temp_zip, files) + size = temp_zip.stat().st_size + temp_zip.unlink() + return size + + +def reduced_pdf_arcname(arcname): + safe_stem = shortened_stem(arcname.stem) + return arcname.with_name(f"{safe_stem}_ridotto_email{arcname.suffix}") + + +def reduced_image_arcname(arcname, target_suffix=None): + safe_stem = shortened_stem(arcname.stem) + suffix = target_suffix if target_suffix else arcname.suffix.lower() + return arcname.with_name(f"{safe_stem}_ridotta_email{suffix}") + + +def reduced_office_arcname(arcname): + safe_stem = shortened_stem(arcname.stem) + return arcname.with_name(f"{safe_stem}_convertito_email.pdf") + + +def shortened_stem(stem, max_length=80): + if len(stem) <= max_length: + return stem + + digest = hashlib.sha1(stem.encode("utf-8")).hexdigest()[:8] + return f"{stem[:max_length]}_{digest}" + + +def find_ghostscript(): + for executable in ("gswin64c", "gswin32c", "gs"): + path = shutil.which(executable) + if path: + return path + return None + + +def pdf_has_significant_text(pdf_path): + doc = fitz.open(pdf_path) + try: + if doc.page_count == 0: + return False + + sample_indexes = sorted({0, doc.page_count // 2, doc.page_count - 1}) + text_length = 0 + for page_index in sample_indexes: + text_length += len(doc.load_page(page_index).get_text("text").strip()) + + return text_length >= 500 + finally: + doc.close() + + +def optimized_pdf_result(source_file, optimized_path, profile, dpi, quality): + optimized_file = BundleFile( + path=optimized_path.resolve(), + arcname=reduced_pdf_arcname(source_file.arcname), + original_path=source_file.path, + ) + + return OptimizedPdf( + original=source_file, + optimized=optimized_file, + original_size=source_file.stat().st_size, + optimized_size=optimized_file.stat().st_size, + kind="pdf", + profile=profile, + dpi=dpi, + quality=quality, + ) + + +def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None): + ghostscript = find_ghostscript() + if not ghostscript: + if log: + log("Ghostscript non trovato: salto ottimizzazione PDF avanzata.") + return None + + optimized_root = Path(output_folder) / "_pdf_ridotti_email" + optimized_path = optimized_root / reduced_pdf_arcname(source_file.arcname) + optimized_path.parent.mkdir(parents=True, exist_ok=True) + + if log: + log( + f"Provo Ghostscript /{profile}: " + f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})" + ) + + command = [ + ghostscript, + "-sDEVICE=pdfwrite", + "-dCompatibilityLevel=1.6", + f"-dPDFSETTINGS=/{profile}", + "-dNOPAUSE", + "-dQUIET", + "-dBATCH", + "-dDetectDuplicateImages=true", + "-dCompressFonts=true", + "-dSubsetFonts=true", + f"-sOutputFile={optimized_path}", + str(source_file.path), + ] + + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=300, + ) + + if completed.returncode != 0 or not optimized_path.exists(): + if log: + error_text = (completed.stderr or completed.stdout or "errore sconosciuto").strip() + log(f"Ghostscript non ha prodotto un PDF valido: {error_text}") + optimized_path.unlink(missing_ok=True) + return None + + return optimized_pdf_result( + source_file, + optimized_path, + profile=f"ghostscript_{profile}", + dpi=profile, + quality=None, + ) + + +def optimize_pdf(source_file, output_folder, profile, dpi, quality, log=None): + optimized_root = Path(output_folder) / "_pdf_ridotti_email" + optimized_path = optimized_root / reduced_pdf_arcname(source_file.arcname) + optimized_path.parent.mkdir(parents=True, exist_ok=True) + + if log: + if dpi: + log( + f"Ottimizzo PDF a {dpi} DPI: " + f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})" + ) + else: + log( + "Provo ottimizzazione PDF senza perdita: " + f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})" + ) + + source_doc = fitz.open(source_file.path) + try: + if dpi is None: + source_doc.save( + optimized_path, + garbage=4, + clean=True, + deflate=True, + deflate_images=True, + deflate_fonts=True, + use_objstms=True, + compression_effort=9, + ) + optimized_file = BundleFile( + path=optimized_path.resolve(), + arcname=reduced_pdf_arcname(source_file.arcname), + original_path=source_file.path, + ) + return OptimizedPdf( + original=source_file, + optimized=optimized_file, + original_size=source_file.stat().st_size, + optimized_size=optimized_file.stat().st_size, + kind="pdf", + profile=profile, + dpi=dpi, + quality=quality, + ) + + optimized_doc = fitz.open() + matrix = fitz.Matrix(dpi / 72, dpi / 72) + + try: + for page_index, source_page in enumerate(source_doc, start=1): + if log: + log(f" pagina {page_index}/{source_doc.page_count}") + + 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.insert_image(page.rect, stream=image_bytes) + + optimized_doc.save( + optimized_path, + garbage=4, + deflate=True, + clean=True, + ) + finally: + optimized_doc.close() + finally: + source_doc.close() + + optimized_file = BundleFile( + path=optimized_path.resolve(), + arcname=reduced_pdf_arcname(source_file.arcname), + original_path=source_file.path, + ) + + return OptimizedPdf( + original=source_file, + optimized=optimized_file, + original_size=source_file.stat().st_size, + optimized_size=optimized_file.stat().st_size, + kind="pdf", + profile=profile, + dpi=dpi, + quality=quality, + ) + + +def optimized_image_result(source_file, optimized_path, arcname, profile, max_side, quality): + optimized_file = BundleFile( + path=optimized_path.resolve(), + arcname=arcname, + original_path=source_file.path, + ) + + return OptimizedPdf( + original=source_file, + optimized=optimized_file, + original_size=source_file.stat().st_size, + optimized_size=optimized_file.stat().st_size, + kind="image", + profile=profile, + dpi=max_side, + quality=quality, + ) + + +def normalize_image_for_format(image, target_format): + if image.mode in ("I;16", "I;16B", "I;16L"): + image = image.point(lambda value: value / 256).convert("L") + + if target_format in {"JPEG", "BMP"} and ( + image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info) + ): + background = Image.new("RGB", image.size, "white") + alpha = image.convert("RGBA").getchannel("A") + background.paste(image.convert("RGBA"), mask=alpha) + return background + + if target_format == "JPEG" and image.mode == "CMYK": + return image + + if target_format in {"JPEG", "BMP", "WEBP"} and image.mode not in {"RGB", "L", "CMYK"}: + return image.convert("RGB") + + if target_format == "PNG" and image.mode not in {"RGB", "RGBA", "L", "P"}: + return image.convert("RGB") + + if target_format == "TIFF" and image.mode == "P": + return image.convert("RGB") + + return image + + +def image_save_options(target_format, quality): + if target_format == "JPEG": + return { + "quality": quality, + "optimize": True, + "progressive": True, + } + + if target_format == "PNG": + return { + "optimize": True, + "compress_level": 9, + } + + if target_format == "TIFF": + return { + "compression": "tiff_adobe_deflate", + } + + if target_format == "WEBP": + return { + "quality": quality, + "method": 6, + } + + return {} + + +def image_target_format(source_path, convert_uncompressed=False): + suffix = source_path.suffix.lower() + if convert_uncompressed and suffix in {".bmp", ".tif", ".tiff"}: + return "JPEG" + if suffix in {".jpg", ".jpeg"}: + return "JPEG" + if suffix == ".png": + return "PNG" + if suffix in {".tif", ".tiff"}: + return "TIFF" + if suffix == ".webp": + return "WEBP" + if suffix == ".bmp": + return "BMP" + return None + + +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) + if target_format is None: + raise ValueError(f"Formato immagine non supportato: {source_file.path.suffix}") + + target_suffix = ".jpg" if target_format == "JPEG" else source_file.path.suffix.lower() + optimized_arcname = reduced_image_arcname(source_file.arcname, target_suffix=target_suffix) + optimized_path = optimized_root / optimized_arcname + optimized_path.parent.mkdir(parents=True, exist_ok=True) + + if log: + conversion_text = "" + if source_file.path.suffix.lower() != target_suffix: + conversion_text = f" -> {target_suffix}" + log( + f"Ottimizzo immagine {profile}{conversion_text}: {source_file.arcname} " + f"({format_mb(source_file.stat().st_size)})" + ) + + with Image.open(source_file.path) as image: + image = ImageOps.exif_transpose(image) + + if getattr(image, "is_animated", False) and log: + log(" immagine animata: uso il primo frame per la copia email.") + image.seek(0) + + image = normalize_image_for_format(image, target_format) + image.thumbnail((max_side, max_side), Image.Resampling.LANCZOS) + + image.save( + optimized_path, + target_format, + **image_save_options(target_format, quality), + ) + + return optimized_image_result(source_file, optimized_path, optimized_arcname, profile, max_side, quality) + + +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, + 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 + + +def optimized_office_result(source_file, optimized_path): + optimized_file = BundleFile( + path=optimized_path.resolve(), + arcname=reduced_office_arcname(source_file.arcname), + original_path=source_file.path, + ) + + return OptimizedPdf( + original=source_file, + optimized=optimized_file, + original_size=source_file.stat().st_size, + optimized_size=optimized_file.stat().st_size, + kind="office", + profile="office_pdf", + dpi=None, + quality=None, + ) + + +def convert_office_to_pdf(source_file, output_folder, log=None): + suffix = source_file.path.suffix.lower() + output_root = Path(output_folder) / "_office_convertiti_pdf" + pdf_path = output_root / reduced_office_arcname(source_file.arcname) + pdf_path.parent.mkdir(parents=True, exist_ok=True) + + if log: + log(f"Converto Office in PDF: {source_file.arcname} ({format_mb(source_file.stat().st_size)})") + + try: + import pythoncom + import win32com.client + except Exception as exc: + if log: + log(f"Conversione Office non disponibile: {exc}") + return None + + pythoncom.CoInitialize() + try: + if suffix in {".doc", ".docx"}: + word = None + document = None + try: + word = win32com.client.DispatchEx("Word.Application") + word.Visible = False + word.DisplayAlerts = 0 + document = word.Documents.Open(str(source_file.path), ReadOnly=True) + document.SaveAs(str(pdf_path), FileFormat=17) + finally: + if document is not None: + document.Close(False) + if word is not None: + word.Quit() + + elif suffix in {".xls", ".xlsx"}: + excel = None + workbook = None + try: + excel = win32com.client.DispatchEx("Excel.Application") + excel.Visible = False + excel.DisplayAlerts = False + workbook = excel.Workbooks.Open(str(source_file.path), ReadOnly=True) + workbook.ExportAsFixedFormat(0, str(pdf_path)) + finally: + if workbook is not None: + workbook.Close(False) + if excel is not None: + excel.Quit() + + elif suffix in {".ppt", ".pptx"}: + powerpoint = None + presentation = None + try: + powerpoint = win32com.client.DispatchEx("PowerPoint.Application") + presentation = powerpoint.Presentations.Open( + str(source_file.path), + WithWindow=False, + ) + presentation.SaveAs(str(pdf_path), 32) + finally: + if presentation is not None: + presentation.Close() + if powerpoint is not None: + powerpoint.Quit() + else: + return None + except Exception as exc: + if log: + log(f"Conversione Office fallita: {exc}") + pdf_path.unlink(missing_ok=True) + return None + finally: + pythoncom.CoUninitialize() + + if not pdf_path.exists(): + if log: + log("Conversione Office fallita: PDF non creato.") + return None + + return optimized_office_result(source_file, pdf_path) + + +def try_convert_office_for_limit(source_file, output_folder, max_zip_size, log=None): + converted = convert_office_to_pdf(source_file, output_folder, log=log) + if converted is None: + return None + + with tempfile.TemporaryDirectory() as temp_folder: + zip_size = compressed_zip_size([converted.optimized], temp_folder) + + if zip_size <= max_zip_size: + return converted + + 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 + + +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) + + for profile_name, dpi, quality in PDF_OPTIMIZE_PROFILES: + if isinstance(dpi, int) and skip_raster_profiles: + if log: + log( + "Il PDF contiene testo selezionabile: salto la ricodifica a immagini " + "per evitare file piu grandi e perdita di qualita." + ) + 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 + else: + optimized = optimize_pdf(source_file, output_folder, profile_name, dpi, quality, log=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." + ) + + return None + + +def split_into_groups( + files, + output_folder, + max_zip_size, + log=None, + confirm_optimization=None, + convert_uncompressed_images=False, + convert_office_to_pdf_enabled=False, +): + groups = [] + current_group = [] + optimized_reports = [] + + # Larger files first usually reduces the number of packages. + files = sorted(files, key=lambda f: f.stat().st_size, reverse=True) + + with tempfile.TemporaryDirectory() as temp_folder: + for index, file_path in enumerate(files, start=1): + if log: + log(f"Analisi file {index}/{len(files)}: {file_path.display_name}") + + candidate_group = current_group + [file_path] + candidate_size = compressed_zip_size(candidate_group, temp_folder) + + if candidate_size <= max_zip_size: + current_group = candidate_group + continue + + single_size = compressed_zip_size([file_path], temp_folder) + if single_size <= max_zip_size: + if current_group: + groups.append(current_group) + current_group = [file_path] + continue + + replacement = None + if file_path.path.suffix.lower() == ".pdf": + replacement = try_optimize_pdf_for_limit( + file_path, + output_folder, + max_zip_size, + log=log, + ) + + elif file_path.path.suffix.lower() in IMAGE_SUFFIXES: + replacement = try_optimize_image_for_limit( + file_path, + output_folder, + max_zip_size, + log=log, + convert_uncompressed=convert_uncompressed_images, + ) + elif convert_office_to_pdf_enabled and file_path.path.suffix.lower() in OFFICE_SUFFIXES: + replacement = try_convert_office_for_limit( + file_path, + output_folder, + max_zip_size, + log=log, + ) + + 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) + replacement_group = current_group + [replacement.optimized] + replacement_group_size = compressed_zip_size(replacement_group, temp_folder) + + if replacement_group_size <= max_zip_size: + current_group = replacement_group + else: + if current_group: + groups.append(current_group) + current_group = [replacement.optimized] + + if log: + labels = { + "pdf": "PDF alleggerito accettato", + "image": "Immagine alleggerita accettata", + "office": "File Office convertito accettato", + } + item_label = labels.get(replacement.kind, "File ottimizzato accettato") + log( + f"{item_label}: {file_path.arcname} -> " + f"{replacement.optimized.arcname} " + f"({format_mb(replacement.original_size)} -> " + 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]) + + if current_group: + groups.append(current_group) + + return groups, optimized_reports + + +def bundle_folder( + source_folder, + output_folder, + max_size_mb, + log=None, + confirm_optimization=None, + convert_uncompressed_images=False, + convert_office_to_pdf_enabled=False, +): + source_folder = Path(source_folder).resolve() + output_folder = Path(output_folder).resolve() + max_zip_size = mb_to_bytes(max_size_mb) + + if not source_folder.exists() or not source_folder.is_dir(): + raise ValueError("La cartella sorgente indicata non esiste.") + + if max_zip_size <= 0: + raise ValueError("La dimensione massima deve essere maggiore di zero.") + + if output_folder == source_folder: + raise ValueError("La cartella di destinazione deve essere diversa dalla sorgente.") + + output_folder.mkdir(parents=True, exist_ok=True) + + for old_zip in output_folder.glob("pacchetto_*.zip"): + old_zip.unlink() + + optimized_root = output_folder / "_pdf_ridotti_email" + if optimized_root.exists(): + for old_pdf in optimized_root.rglob("*.pdf"): + old_pdf.unlink() + + optimized_image_root = output_folder / "_immagini_ridotte_email" + if optimized_image_root.exists(): + for old_image in optimized_image_root.rglob("*"): + if old_image.is_file(): + old_image.unlink() + + office_pdf_root = output_folder / "_office_convertiti_pdf" + if office_pdf_root.exists(): + for old_pdf in office_pdf_root.rglob("*.pdf"): + old_pdf.unlink() + + if log: + log(f"Sorgente: {source_folder}") + log(f"Destinazione: {output_folder}") + log(f"Limite pacchetto: {float(max_size_mb):.2f} MB") + log( + "Conversione BMP/TIFF in JPEG: " + + ("attiva" if convert_uncompressed_images else "disattiva") + ) + log( + "Conversione Office in PDF: " + + ("attiva" if convert_office_to_pdf_enabled else "disattiva") + ) + + files = collect_files(source_folder, exclude_folder=output_folder) + + if not files: + raise ValueError("Nessun file trovato nella cartella sorgente.") + + if log: + total_size = sum(f.stat().st_size for f in files) + log(f"Trovati {len(files)} file ({format_mb(total_size)} originali).") + + groups, optimized_reports = split_into_groups( + files, + output_folder, + max_zip_size, + log=log, + confirm_optimization=confirm_optimization, + convert_uncompressed_images=convert_uncompressed_images, + convert_office_to_pdf_enabled=convert_office_to_pdf_enabled, + ) + results = [] + + for i, group in enumerate(groups, start=1): + zip_name = f"pacchetto_{i:03d}.zip" + zip_path = output_folder / zip_name + create_zip(zip_path, group) + + original_size = sum(f.stat().st_size for f in group) + zip_size = zip_path.stat().st_size + results.append((zip_path, len(group), original_size, zip_size)) + + if log: + log( + f"Creato {zip_name} - " + f"{len(group)} file - " + f"originale {format_mb(original_size)} - " + f"zip {format_mb(zip_size)}" + ) + + if optimized_reports and log: + log("") + log("File ottimizzati:") + for report in optimized_reports: + profile_text = optimization_label(report) + item_label = "PDF" if report.kind == "pdf" else "immagine" + log( + f"- {item_label}: {report.original.arcname} -> {report.optimized.arcname} " + f"({format_mb(report.original_size)} -> " + f"{format_mb(report.optimized_size)}, {profile_text})" + ) + + if log: + log("") + log(f"Finito. ZIP creati in: {output_folder}") + + return results + + +def pdf_page_count(pdf_path): + doc = fitz.open(pdf_path) + try: + return doc.page_count + finally: + doc.close() + + +def render_pdf_preview(pdf_path, page_index=0, max_size=(360, 480)): + 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) + 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: + image = ImageOps.exif_transpose(image) + if getattr(image, "is_animated", False): + image.seek(0) + image = normalize_image_for_format(image, "JPEG") + image.thumbnail(max_size, Image.Resampling.LANCZOS) + return image.copy() + + +def render_preview(report, path, page_index=0): + if report.kind == "pdf": + return render_pdf_preview(path, page_index) + return render_image_preview(path) + + +class OptimizationDialog(ctk.CTkToplevel): + def __init__(self, parent, report): + super().__init__(parent) + self.report = report + self.result = False + self.page_index = 0 + self.page_count = 1 + if report.kind == "pdf": + self.page_count = min( + pdf_page_count(report.original.path), + pdf_page_count(report.optimized.path), + ) + self.title("File troppo grande") + self.geometry("900x720") + self.minsize(780, 620) + self.transient(parent) + self.grab_set() + + 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( + row=0, column=0, columnspan=2, padx=18, pady=(18, 10), sticky="w" + ) + + pager = ctk.CTkFrame(self, fg_color="transparent") + pager.grid(row=1, column=0, columnspan=2, padx=18, pady=(0, 8), sticky="ew") + pager.grid_columnconfigure(1, weight=1) + + self.prev_button = ctk.CTkButton(pager, text="<", width=44, command=self.previous_page) + self.prev_button.grid(row=0, column=0, padx=(0, 10)) + + self.page_label = ctk.CTkLabel(pager, text="") + self.page_label.grid(row=0, column=1) + + self.next_button = ctk.CTkButton(pager, text=">", width=44, command=self.next_page) + self.next_button.grid(row=0, column=2, padx=(10, 0)) + if report.kind != "pdf": + self.prev_button.grid_remove() + self.next_button.grid_remove() + + self.original_image = None + self.optimized_image = None + + self._build_preview_column( + 0, + "Originale", + format_mb(report.original_size), + ) + self._build_preview_column( + 1, + f"Ridotto ({optimization_label(report)})", + format_mb(report.optimized_size), + ) + self.update_previews() + + 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( + 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.protocol("WM_DELETE_WINDOW", self.reject) + + def _build_preview_column(self, column, label, size_text): + frame = ctk.CTkFrame(self, corner_radius=8) + frame.grid(row=2, column=column, padx=(18 if column == 0 else 9, 18 if column == 1 else 9), pady=8, sticky="nsew") + frame.grid_columnconfigure(0, weight=1) + frame.grid_rowconfigure(1, weight=1) + + 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") + + if column == 0: + self.original_image_label = image_label + else: + self.optimized_image_label = image_label + + def update_previews(self): + self.original_image = ImageTk.PhotoImage( + render_preview(self.report, self.report.original.path, self.page_index) + ) + self.optimized_image = ImageTk.PhotoImage( + render_preview(self.report, self.report.optimized.path, self.page_index) + ) + self.original_image_label.configure(image=self.original_image) + self.optimized_image_label.configure(image=self.optimized_image) + if self.report.kind == "pdf": + self.page_label.configure(text=f"Pagina {self.page_index + 1} / {self.page_count}") + else: + self.page_label.configure(text=f"{optimization_label(self.report)}") + self.prev_button.configure(state="normal" if self.page_index > 0 else "disabled") + self.next_button.configure( + state="normal" if self.page_index < self.page_count - 1 else "disabled" + ) + + def previous_page(self): + if self.page_index > 0: + self.page_index -= 1 + self.update_previews() + + def next_page(self): + if self.page_index < self.page_count - 1: + self.page_index += 1 + self.update_previews() + + def accept(self): + self.result = True + self.destroy() + + def reject(self): + self.result = False + self.destroy() + + +class LargeEmailSizeDialog(ctk.CTkToplevel): + def __init__(self, parent, size_value): + super().__init__(parent) + self.result = None + self.title("Dimensione allegati email") + self.geometry("560x300") + self.resizable(False, False) + self.transient(parent) + self.grab_set() + + self.grid_columnconfigure(0, weight=1) + + ctk.CTkLabel( + self, + text=f"Hai impostato pacchetti da {size_value:.1f} MB.", + font=ctk.CTkFont(size=16, weight="bold"), + ).grid(row=0, column=0, padx=22, pady=(22, 10), sticky="w") + + ctk.CTkLabel( + self, + text=( + "Questa dimensione supera 18 MB. Molti provider di posta elettronica " + "applicano limiti agli allegati, e la codifica email puo aumentare " + "la dimensione effettiva del messaggio." + ), + wraplength=510, + justify="left", + ).grid(row=1, column=0, padx=22, pady=(0, 18), sticky="w") + + buttons = ctk.CTkFrame(self, fg_color="transparent") + buttons.grid(row=2, column=0, padx=22, pady=(8, 22), sticky="e") + + ctk.CTkButton(buttons, text="Annulla", width=100, command=self.cancel).grid( + row=0, column=0, padx=(0, 10) + ) + ctk.CTkButton(buttons, text="Riduci a 18 MB", width=130, command=self.reduce).grid( + row=0, column=1, padx=(0, 10) + ) + ctk.CTkButton(buttons, text="Mantieni", width=110, command=self.keep).grid( + row=0, column=2 + ) + + self.protocol("WM_DELETE_WINDOW", self.cancel) + self.after(100, self.lift) + self.after(120, self.focus_force) + + def keep(self): + self.result = "keep" + self.destroy() + + def reduce(self): + self.result = "reduce" + self.destroy() + + def cancel(self): + self.result = "cancel" + self.destroy() + + +class ZipBundlerApp(ctk.CTk): + def __init__(self): + super().__init__() + + self.title("ZipBundler versione 1.0") + self.geometry("900x640") + self.minsize(820, 580) + + ctk.set_appearance_mode("system") + ctk.set_default_color_theme("green") + + self.source_var = ctk.StringVar() + self.output_var = ctk.StringVar() + self.size_var = ctk.StringVar(value=str(DEFAULT_MAX_ZIP_SIZE_MB)) + self.allow_large_email_var = ctk.BooleanVar(value=False) + self.convert_uncompressed_var = ctk.BooleanVar(value=False) + self.convert_office_var = ctk.BooleanVar(value=False) + self.is_running = False + + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(2, weight=1) + + self._build_header() + self._build_form() + self._build_log() + self.size_var.trace_add("write", lambda *_: self.update_size_warning()) + self.update_size_warning() + + def _build_header(self): + header = ctk.CTkFrame(self, fg_color="transparent") + header.grid(row=0, column=0, padx=22, pady=(18, 8), sticky="ew") + header.grid_columnconfigure(0, weight=1) + + ctk.CTkLabel( + header, + text="ZipBundler", + font=ctk.CTkFont(size=24, weight="bold"), + ).grid(row=0, column=0, sticky="w") + + ctk.CTkLabel( + header, + text="Pacchetti ZIP entro soglia, con riduzione guidata di PDF, immagini e file Office.", + text_color=("gray35", "gray72"), + ).grid(row=1, column=0, pady=(2, 0), sticky="w") + + def _build_form(self): + form = ctk.CTkFrame(self, corner_radius=8) + form.grid(row=1, column=0, padx=22, pady=(0, 12), sticky="ew") + form.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel(form, text="Cartella sorgente", font=ctk.CTkFont(weight="bold")).grid( + row=0, column=0, padx=(16, 10), pady=(16, 8), sticky="w" + ) + ctk.CTkEntry(form, textvariable=self.source_var).grid( + row=0, column=1, padx=0, pady=(16, 8), sticky="ew" + ) + ctk.CTkButton(form, text="Sfoglia", width=96, command=self.pick_source).grid( + row=0, column=2, padx=16, pady=(16, 8) + ) + + ctk.CTkLabel(form, text="Destinazione ZIP", font=ctk.CTkFont(weight="bold")).grid( + row=1, column=0, padx=(16, 10), pady=8, sticky="w" + ) + ctk.CTkEntry(form, textvariable=self.output_var).grid( + row=1, column=1, padx=0, pady=8, sticky="ew" + ) + ctk.CTkButton(form, text="Sfoglia", width=96, command=self.pick_output).grid( + row=1, column=2, padx=16, pady=8 + ) + + ctk.CTkLabel( + form, + text="Dimensione max pacchetto (MB)", + font=ctk.CTkFont(weight="bold"), + ).grid( + row=2, column=0, padx=(16, 10), pady=(8, 16), sticky="w" + ) + size_spinbox = ctk.CTkFrame(form, fg_color="transparent") + size_spinbox.grid(row=2, column=1, padx=0, pady=(8, 16), sticky="w") + + ctk.CTkButton(size_spinbox, text="-", width=34, command=lambda: self.step_size(-1)).grid( + row=0, column=0, padx=(0, 6) + ) + ctk.CTkEntry(size_spinbox, textvariable=self.size_var, width=84, justify="center").grid( + row=0, column=1 + ) + ctk.CTkButton(size_spinbox, text="+", width=34, command=lambda: self.step_size(1)).grid( + row=0, column=2, padx=(6, 0) + ) + + self.large_size_panel = ctk.CTkFrame(form, corner_radius=8, fg_color="#fff4e5") + self.large_size_panel.grid( + row=3, column=0, columnspan=3, padx=16, pady=(0, 14), sticky="ew" + ) + self.large_size_panel.grid_columnconfigure(0, weight=1) + + self.large_size_warning = ctk.CTkLabel( + self.large_size_panel, + text=( + "Oltre 18 MB gli allegati possono essere rifiutati da molti provider email." + ), + text_color="#7a3e00", + wraplength=520, + justify="left", + ) + self.large_size_warning.grid(row=0, column=0, padx=12, pady=(10, 4), sticky="w") + + self.large_size_checkbox = ctk.CTkCheckBox( + self.large_size_panel, + text="Accetto questo limite", + variable=self.allow_large_email_var, + text_color="#7a3e00", + ) + self.large_size_checkbox.grid(row=1, column=0, padx=12, pady=(0, 10), sticky="w") + + ctk.CTkButton( + self.large_size_panel, + text="Riduci a 18 MB", + width=120, + command=lambda: self.size_var.set("18"), + fg_color="#a15c00", + hover_color="#7a3e00", + ).grid(row=0, column=1, rowspan=2, padx=12, pady=10, sticky="e") + + options = ctk.CTkFrame(form, fg_color="transparent") + options.grid(row=5, column=0, columnspan=2, padx=16, pady=(0, 16), sticky="w") + + ctk.CTkLabel( + options, + text="Opzioni conversione", + font=ctk.CTkFont(weight="bold"), + text_color=("gray30", "gray75"), + ).grid(row=0, column=0, pady=(0, 8), sticky="w") + + self.convert_checkbox = ctk.CTkCheckBox( + options, + text="Converti formati non compressi in JPEG (es. BMP/TIFF)", + variable=self.convert_uncompressed_var, + ) + self.convert_checkbox.grid(row=1, column=0, pady=(0, 8), sticky="w") + + self.office_checkbox = ctk.CTkCheckBox( + options, + text="Converti file Office grandi in PDF (es. DOCX -> PDF)", + variable=self.convert_office_var, + ) + self.office_checkbox.grid(row=2, column=0, sticky="w") + + self.start_button = ctk.CTkButton( + form, + text="Crea pacchetti ZIP", + width=160, + height=36, + command=self.start_bundle, + ) + self.start_button.grid(row=6, column=2, padx=16, pady=(0, 16)) + + def _build_log(self): + body = ctk.CTkFrame(self, corner_radius=8) + body.grid(row=2, column=0, padx=22, pady=(0, 22), sticky="nsew") + body.grid_columnconfigure(0, weight=1) + body.grid_rowconfigure(2, weight=1) + + ctk.CTkLabel( + body, + text="Avanzamento", + font=ctk.CTkFont(size=15, weight="bold"), + ).grid(row=0, column=0, padx=16, pady=(14, 8), sticky="w") + + self.progress = ctk.CTkProgressBar(body, mode="indeterminate") + self.progress.grid(row=1, column=0, padx=16, pady=(0, 10), sticky="ew") + self.progress.set(0) + + self.log_box = ctk.CTkTextbox(body, wrap="word") + self.log_box.grid(row=2, column=0, padx=16, pady=(0, 16), sticky="nsew") + self.log_box.configure(state="disabled") + + def pick_source(self): + folder = filedialog.askdirectory(title="Scegli la cartella da comprimere") + if not folder: + return + + self.source_var.set(folder) + if not self.output_var.get(): + source = Path(folder) + self.output_var.set(str(source.parent / f"{source.name}_zip_{self.size_var.get()}mb")) + + def pick_output(self): + folder = filedialog.askdirectory(title="Scegli dove salvare i pacchetti ZIP") + if folder: + self.output_var.set(folder) + + def append_log(self, text): + self.log_box.configure(state="normal") + self.log_box.insert("end", text + "\n") + self.log_box.see("end") + self.log_box.configure(state="disabled") + + def set_running(self, running): + self.is_running = running + self.start_button.configure(state="disabled" if running else "normal") + if running: + self.progress.start() + else: + self.progress.stop() + self.progress.set(0) + + def step_size(self, delta): + try: + current = float(self.size_var.get().strip().replace(",", ".")) + except ValueError: + current = DEFAULT_MAX_ZIP_SIZE_MB + + next_value = max(1, current + delta) + if next_value.is_integer(): + self.size_var.set(str(int(next_value))) + else: + self.size_var.set(f"{next_value:.1f}") + + def update_size_warning(self): + try: + size_value = float(self.size_var.get().strip().replace(",", ".")) + except ValueError: + size_value = DEFAULT_MAX_ZIP_SIZE_MB + + if size_value > 18: + self.large_size_warning.configure( + text="Oltre 18 MB gli allegati possono essere rifiutati da molti provider email." + ) + self.large_size_panel.grid() + else: + self.allow_large_email_var.set(False) + self.large_size_panel.grid_remove() + + def confirm_optimization(self, report): + event = threading.Event() + answer = {"value": False} + + def show_dialog(): + dialog = OptimizationDialog(self, report) + self.wait_window(dialog) + answer["value"] = dialog.result + event.set() + + self.after(0, show_dialog) + event.wait() + return answer["value"] + + def confirm_large_email_size(self, size_value): + dialog = LargeEmailSizeDialog(self, size_value) + self.wait_window(dialog) + return dialog.result + + def start_bundle(self): + if self.is_running: + return + + source = self.source_var.get().strip() + output = self.output_var.get().strip() + size = self.size_var.get().strip().replace(",", ".") + convert_uncompressed = self.convert_uncompressed_var.get() + convert_office = self.convert_office_var.get() + + if not source: + messagebox.showerror("Dato mancante", "Scegli la cartella sorgente.") + return + + if not output: + messagebox.showerror("Dato mancante", "Scegli la cartella di destinazione.") + return + + try: + size_value = float(size) + if size_value <= 0: + raise ValueError + except ValueError: + messagebox.showerror( + "Valore non valido", + "Inserisci una dimensione massima valida, per esempio 20.", + ) + return + + if size_value > 18 and not self.allow_large_email_var.get(): + self.large_size_warning.configure( + text=( + "Spunta \"Accetto questo limite\" oppure riduci a 18 MB per continuare." + ) + ) + self.large_size_panel.grid() + return + + self.log_box.configure(state="normal") + self.log_box.delete("1.0", "end") + self.log_box.configure(state="disabled") + self.set_running(True) + + worker = threading.Thread( + target=self.run_bundle, + args=(source, output, size_value, convert_uncompressed, convert_office), + daemon=True, + ) + worker.start() + + def run_bundle(self, source, output, size, convert_uncompressed, convert_office): + def thread_log(message): + self.after(0, self.append_log, message) + + try: + bundle_folder( + source, + output, + size, + log=thread_log, + confirm_optimization=self.confirm_optimization, + convert_uncompressed_images=convert_uncompressed, + convert_office_to_pdf_enabled=convert_office, + ) + except Exception as exc: + self.after(0, messagebox.showerror, "Errore", str(exc)) + self.after(0, self.append_log, f"ERRORE: {exc}") + finally: + self.after(0, self.set_running, False) + + +def run_gui(): + app = ZipBundlerApp() + app.mainloop() + + +def parse_args(argv): + parser = argparse.ArgumentParser(description="Crea pacchetti ZIP con dimensione massima reale.") + parser.add_argument("source", nargs="?", help="Cartella sorgente da comprimere.") + parser.add_argument("--output", "-o", help="Cartella dove salvare gli ZIP.") + parser.add_argument( + "--size-mb", + "-s", + type=float, + default=DEFAULT_MAX_ZIP_SIZE_MB, + help=f"Dimensione massima di ogni ZIP in MB. Default: {DEFAULT_MAX_ZIP_SIZE_MB}.", + ) + parser.add_argument( + "--convert-uncompressed-images", + action="store_true", + help="Converte immagini BMP/TIFF in JPEG solo per la copia ridotta email.", + ) + parser.add_argument( + "--convert-office-to-pdf", + action="store_true", + help="Converte file Office grandi in PDF solo quando superano il limite.", + ) + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv if argv is not None else sys.argv[1:]) + + if not args.source: + run_gui() + return + + source_folder = Path(args.source).resolve() + output_folder = Path(args.output).resolve() if args.output else source_folder.parent / f"{source_folder.name}_zip_20mb" + + bundle_folder( + source_folder, + output_folder, + args.size_mb, + log=print, + convert_uncompressed_images=args.convert_uncompressed_images, + convert_office_to_pdf_enabled=args.convert_office_to_pdf, + ) + + +if __name__ == "__main__": + main()