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(): bundled_root = getattr(sys, "_MEIPASS", None) if bundled_root: bundled_ghostscript = Path(bundled_root) / "ghostscript" for executable in ("gswin64c.exe", "gswin32c.exe", "gs.exe"): matches = list(bundled_ghostscript.rglob(executable)) if matches: return str(matches[0]) 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() try: import pyi_splash pyi_splash.close() except Exception: pass 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()