Files
zipbundler/zipbundler.py

1499 lines
49 KiB
Python

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