Files
zipbundler/zipbundler.py

1674 lines
59 KiB
Python

import argparse
import hashlib
import shutil
import subprocess
import sys
import tempfile
import threading
import warnings
import zipfile
from contextlib import contextmanager
from dataclasses import dataclass, replace
from pathlib import Path
from tkinter import Canvas, 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),
)
PDF_AGGRESSIVE_PROFILES = ((72, 45), (60, 38), (48, 30), (36, 24), (24, 18))
IMAGE_AGGRESSIVE_PROFILES = ((1000, 50), (800, 40), (600, 30), (400, 24), (200, 18), (100, 12))
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"}
OFFICE_SUFFIXES = {".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"}
IMAGE_OPTIMIZE_PROFILES = (
("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
zip_size: int = 0
max_zip_size: int = 0
can_retry: bool = True
next_step: str = ""
class BundleCancelled(Exception):
pass
def mb_to_bytes(size_mb):
return int(float(size_mb) * 1024 * 1024)
def format_mb(size):
return f"{size / 1024 / 1024:.2f} MB"
def optimization_label(report):
if report.profile == "originale":
return "originale: nessuna riduzione utile"
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, f"lato massimo {report.dpi} px, qualita {report.quality}")
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, dpi=None, quality=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 {str(dpi) + ' DPI' if dpi else '/' + 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}",
]
if dpi is not None:
for kind in ("Color", "Gray", "Mono"):
command.extend([
f"-dDownsample{kind}Images=true",
f"-d{kind}ImageResolution={dpi}",
f"-d{kind}ImageDownsampleThreshold=1.0",
])
command.extend(["-dPassThroughJPEGImages=false", "-dPassThroughJPXImages=false"])
for kind in ("Color", "Gray"):
command.extend([
f"-d{kind}ImageDownsampleType=/Bicubic",
f"-dAutoFilter{kind}Images=false",
f"-d{kind}ImageFilter=/DCTEncode",
])
qfactor = 1 - (quality or 75) / 100
command.extend([
"-c",
f"<< /ColorImageDict << /QFactor {qfactor:.2f} >> "
f"/GrayImageDict << /QFactor {qfactor:.2f} >> >> setdistillerparams",
"-f",
])
command.append(str(source_file.path))
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=300,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if completed.returncode != 0 or not optimized_path.exists():
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"dpi_{dpi}" if dpi else f"ghostscript_{profile}",
dpi=dpi if dpi else profile,
quality=quality,
)
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=source_page.rect.width, height=source_page.rect.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
@contextmanager
def open_image(path, log=None):
# Keep Pillow's hard pixel limit; route its advisory warning to the application log.
with warnings.catch_warnings():
warnings.simplefilter("ignore", Image.DecompressionBombWarning)
image = Image.open(path)
try:
if Image.MAX_IMAGE_PIXELS and image.width * image.height > Image.MAX_IMAGE_PIXELS and log:
log(f"Immagine molto grande ({image.width} x {image.height} pixel): "
"la riduzione puo richiedere piu memoria e tempo.")
yield image
finally:
image.close()
def optimize_image(source_file, output_folder, profile, max_side, quality, log=None, convert_uncompressed=False):
optimized_root = Path(output_folder) / "_immagini_ridotte_email"
target_format = image_target_format(source_file.path, convert_uncompressed=convert_uncompressed)
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 open_image(source_file.path, log=log) as image:
if image.format == "JPEG":
image.draft(image.mode, (max_side, max_side))
image = ImageOps.exif_transpose(image)
if getattr(image, "is_animated", False) and log:
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):
attempts = [
lambda p=p, side=side, q=q: optimize_image(
source_file, output_folder, p, side, q, log=log,
convert_uncompressed=convert_uncompressed,
)
for p, side, q in IMAGE_OPTIMIZE_PROFILES
]
return select_standard_result(source_file, "image", attempts, max_zip_size, log)
def optimized_office_result(source_file, optimized_path):
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, confirm_optimization=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 replace(converted, zip_size=zip_size, max_zip_size=max_zip_size)
report, _ = choose_pdf_for_limit(
converted.optimized, output_folder, max_zip_size, log=log,
confirm_optimization=confirm_optimization,
)
return report
def measured_report(report, max_zip_size):
with tempfile.TemporaryDirectory() as temp_folder:
size = compressed_zip_size([report.optimized], temp_folder)
return replace(report, zip_size=size, max_zip_size=max_zip_size)
def select_standard_result(source_file, kind, attempts, max_zip_size, log=None):
size = source_file.stat().st_size
best = measured_report(
OptimizedPdf(source_file, source_file, size, size, kind, "originale", None, None), max_zip_size,
)
# Profiles write to the same destination. Preserve the best bytes, not just its path.
with tempfile.TemporaryDirectory() as temp_folder:
saved = Path(temp_folder) / "best"
for attempt in attempts:
try:
candidate = attempt()
if candidate is None:
continue
candidate = measured_report(candidate, max_zip_size)
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
if log:
log(f"Profilo standard non riuscito: {exc}")
continue
if log:
log(f"{optimization_label(candidate)}: {format_mb(candidate.zip_size)} nello ZIP; "
f"limite {format_mb(max_zip_size)}.")
if candidate.zip_size <= max_zip_size:
return candidate
if candidate.zip_size < best.zip_size:
best = candidate
shutil.copyfile(candidate.optimized.path, saved)
if best.optimized.path != source_file.path:
shutil.copyfile(saved, best.optimized.path)
return best
def try_optimize_pdf_for_limit(source_file, output_folder, max_zip_size, log=None):
skip_raster_profiles = pdf_has_significant_text(source_file.path)
attempts = []
for profile_name, dpi, quality in PDF_OPTIMIZE_PROFILES:
if isinstance(dpi, int) and skip_raster_profiles:
if log:
log(
"Il PDF contiene testo selezionabile: salto la ricodifica a immagini "
"per evitare file piu grandi e perdita di qualita."
)
break
if isinstance(dpi, str):
attempts.append(lambda dpi=dpi: optimize_pdf_with_ghostscript(
source_file, output_folder, dpi, log=log,
))
else:
attempts.append(lambda p=profile_name, dpi=dpi, q=quality: optimize_pdf(
source_file, output_folder, p, dpi, q, log=log,
))
return select_standard_result(source_file, "pdf", attempts, max_zip_size, log)
def pdf_at_dpi(source_file, output_folder, dpi, quality, log=None, raster=False):
if not raster:
try:
report = optimize_pdf_with_ghostscript(
source_file, output_folder, "default", log=log, dpi=dpi, quality=quality,
)
if report is not None:
return report
except (OSError, subprocess.SubprocessError) as exc:
if log:
log(f"Ghostscript a {dpi} DPI non riuscito: {exc}")
if log:
log(f"Ricodifica delle pagine a immagini: {dpi} DPI, qualita JPEG {quality}.")
return optimize_pdf(source_file, output_folder, f"raster_{dpi}", dpi, quality, log=log)
def negotiate_compression(report, attempts, max_zip_size, confirm_optimization, log=None):
if report.zip_size <= max_zip_size:
if log:
log("Profilo standard entro il limite: copia accettata automaticamente.")
return report
if confirm_optimization is None:
raise ValueError("Il file resta fuori limite: avvia la GUI per scegliere una compressione aggiuntiva.")
for index in range(len(attempts) + 1):
next_step = attempts[index][0] if index < len(attempts) else ""
report = replace(report, can_retry=index < len(attempts), next_step=next_step)
answer = confirm_optimization(report)
if answer is True:
return report
if answer is None:
raise BundleCancelled("Elaborazione annullata: nessun risultato rifiutato e stato impacchettato.")
if not report.can_retry:
raise BundleCancelled("Profili esauriti: risultato non accettato, elaborazione annullata.")
try:
candidate = attempts[index][1]()
report = measured_report(candidate, max_zip_size)
if log:
log(f"Compressione aggiuntiva: {optimization_label(report)}, "
f"{format_mb(report.zip_size)} nello ZIP. Richiesta approvazione.")
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
# Do not leave a report pointing at an overwritten or partial candidate.
raise ValueError(f"Compressione aggiuntiva non riuscita; nessun pacchetto creato: {exc}") from exc
raise BundleCancelled("Nessuna compressione accettata.")
def choose_pdf_for_limit(source_file, output_folder, max_zip_size, log=None, confirm_optimization=None):
report = try_optimize_pdf_for_limit(source_file, output_folder, max_zip_size, log=log)
attempts = []
for raster in ((False, True) if find_ghostscript() else (True,)):
for dpi, quality in PDF_AGGRESSIVE_PROFILES:
description = (f"Pagine a immagini, {dpi} DPI: il testo non sara selezionabile."
if raster else f"Ghostscript, immagini a {dpi} DPI.")
attempts.append((description, lambda dpi=dpi, q=quality, raster=raster: pdf_at_dpi(
source_file, output_folder, dpi, q, log=log, raster=raster,
)))
report = negotiate_compression(report, attempts, max_zip_size, confirm_optimization, log)
return report, report.zip_size > max_zip_size
def choose_image_for_limit(source_file, output_folder, max_zip_size, log=None,
convert_uncompressed=False, confirm_optimization=None):
report = try_optimize_image_for_limit(
source_file, output_folder, max_zip_size, log=log, convert_uncompressed=convert_uncompressed,
)
attempts = [
(f"Lato massimo {side} pixel, qualita {quality}.",
lambda side=side, q=quality: optimize_image(
source_file, output_folder, f"aggressivo_{side}", side, q, log=log,
convert_uncompressed=convert_uncompressed,
)) for side, quality in IMAGE_AGGRESSIVE_PROFILES
]
return negotiate_compression(report, attempts, max_zip_size, confirm_optimization, log)
def split_into_groups(
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, _ = choose_pdf_for_limit(
file_path,
output_folder,
max_zip_size,
log=log,
confirm_optimization=confirm_optimization,
)
elif file_path.path.suffix.lower() in IMAGE_SUFFIXES:
replacement = choose_image_for_limit(
file_path,
output_folder,
max_zip_size,
log=log,
convert_uncompressed=convert_uncompressed_images,
confirm_optimization=confirm_optimization,
)
elif convert_office_to_pdf_enabled and file_path.path.suffix.lower() in OFFICE_SUFFIXES:
replacement = try_convert_office_for_limit(
file_path,
output_folder,
max_zip_size,
log=log,
confirm_optimization=confirm_optimization,
)
if replacement:
optimized_reports.append(replacement)
if replacement.zip_size > max_zip_size:
if current_group:
groups.append(current_group)
current_group = []
groups.append([replacement.optimized])
if log:
log(f"Pacchetto separato FUORI LIMITE, accettato esplicitamente: "
f"{file_path.arcname}, {format_mb(replacement.zip_size)} nello ZIP.")
continue
replacement_group = current_group + [replacement.optimized]
replacement_group_size = compressed_zip_size(replacement_group, temp_folder)
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:
raise ValueError(
f"{file_path.arcname}: {format_mb(single_size)} nello ZIP, oltre il limite. "
"Nessuna conversione disponibile o abilitata per questo file; "
"elaborazione interrotta senza creare pacchetti fuori limite non approvati."
)
if current_group:
groups.append(current_group)
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), zoom=None):
doc = fitz.open(pdf_path)
try:
page_index = max(0, min(page_index, doc.page_count - 1))
page = doc.load_page(page_index)
scale = (96 / 72 * zoom) if zoom is not None else min(
max_size[0] / page.rect.width, max_size[1] / page.rect.height,
)
pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)
image = Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
return image
finally:
doc.close()
def render_image_preview(image_path, max_size=(360, 480)):
with open_image(image_path) as image:
if image.format == "JPEG":
image.draft(image.mode, max_size)
image = ImageOps.exif_transpose(image)
if getattr(image, "is_animated", False):
image.seek(0)
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, zoom=None, max_size=(360, 480)):
if report.kind == "pdf":
return render_pdf_preview(path, page_index, max_size=max_size, zoom=zoom)
return render_image_preview(path)
class OptimizationDialog(ctk.CTkToplevel):
def __init__(self, parent, report):
super().__init__(parent)
self.report = report
self.result = None
self.page_index = 0
self.page_count = 1
self.zoom = None
self.canvases = []
if report.kind == "pdf":
self.page_count = min(
pdf_page_count(report.original.path),
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)
oversized = report.zip_size > report.max_zip_size
status = "FUORI LIMITE" if oversized else "ENTRO IL LIMITE - qualita da approvare"
title = (f"{report.original.arcname.name}\n{status}: ZIP {format_mb(report.zip_size)}; "
f"limite {format_mb(report.max_zip_size)}.\n")
title += (f"Prossimo tentativo: {report.next_step}" if report.can_retry else
"Ultimo profilo disponibile. Accetta questo risultato oppure annulla l'elaborazione.")
self.title_label = ctk.CTkLabel(self, text=title, wraplength=720, font=ctk.CTkFont(size=16, weight="bold"))
self.title_label.grid(
row=0, column=0, columnspan=2, padx=18, pady=(18, 10), sticky="w"
)
self.bind("<Configure>", lambda event: self.title_label.configure(
wraplength=max(300, self.winfo_width() - 50)) if event.widget == self else None)
pager = ctk.CTkFrame(self, fg_color="transparent")
pager.grid(row=1, column=0, columnspan=2, padx=18, pady=(0, 8), sticky="ew")
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()
else:
self.zoom_menu = ctk.CTkOptionMenu(
pager, values=["Adatta", "50%", "100%", "150%", "200%", "300%", "400%"],
width=100, command=self.set_zoom,
)
self.zoom_menu.set("Adatta")
self.zoom_menu.grid(row=0, column=3, padx=(16, 0))
self.original_image = None
self.optimized_image = None
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="Annulla elaborazione", width=160, command=self.cancel).grid(
row=0, column=0, padx=(0, 10)
)
self.more_button = ctk.CTkButton(
buttons, text="Rifiuta e comprimi di piu", width=190, command=self.reject,
state="normal" if report.can_retry else "disabled",
)
self.more_button.grid(row=0, column=1, padx=(0, 10))
ctk.CTkButton(buttons, text="Accetta fuori limite" if oversized else "Accetta risultato",
width=180, command=self.accept).grid(
row=0, column=2
)
self.protocol("WM_DELETE_WINDOW", self.cancel)
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"
)
canvas = Canvas(frame, background="#333333", highlightthickness=0, width=360, height=420)
canvas.grid(row=1, column=0, padx=(8, 0), sticky="nsew")
vertical = ctk.CTkScrollbar(frame, command=lambda *args: self.scroll_previews("y", *args))
vertical.grid(row=1, column=1, sticky="ns")
horizontal = ctk.CTkScrollbar(
frame, orientation="horizontal", command=lambda *args: self.scroll_previews("x", *args),
)
horizontal.grid(row=2, column=0, sticky="ew", padx=8)
canvas.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set)
canvas.bind("<MouseWheel>", self.scroll_wheel)
canvas.bind("<Shift-MouseWheel>", lambda event: self.scroll_wheel(event, "x"))
self.canvases.append(canvas)
def scroll_previews(self, axis, *args):
for canvas in self.canvases:
getattr(canvas, axis + "view")(*args)
def scroll_wheel(self, event, axis="y"):
self.scroll_previews(axis, "scroll", -1 if event.delta > 0 else 1, "units")
return "break"
def set_zoom(self, value):
self.zoom = None if value == "Adatta" else int(value.rstrip("%")) / 100
self.update_previews()
def update_previews(self):
self.update_idletasks()
max_size = (max(100, self.canvases[0].winfo_width() - 8),
max(100, self.canvases[0].winfo_height() - 8))
self.original_image = ImageTk.PhotoImage(
render_preview(self.report, self.report.original.path, self.page_index, self.zoom, max_size)
)
self.optimized_image = ImageTk.PhotoImage(
render_preview(self.report, self.report.optimized.path, self.page_index, self.zoom, max_size)
)
for canvas, photo in zip(self.canvases, (self.original_image, self.optimized_image)):
canvas.delete("all")
x = max(0, (canvas.winfo_width() - photo.width()) // 2)
canvas.create_image(x, 0, image=photo, anchor="nw")
canvas.configure(scrollregion=(0, 0, max(photo.width(), canvas.winfo_width()), photo.height()))
canvas.xview_moveto(0)
canvas.yview_moveto(0)
if self.report.kind == "pdf":
self.page_label.configure(text=f"Pagina {self.page_index + 1} / {self.page_count}")
else:
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()
def cancel(self):
self.result = None
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):
return self.ask_worker_dialog(lambda: OptimizationDialog(self, report))
def ask_worker_dialog(self, factory):
event = threading.Event()
answer = {"value": False}
def show_dialog():
try:
dialog = factory()
self.wait_window(dialog)
answer["value"] = dialog.result
except Exception as exc:
answer["error"] = exc
finally:
event.set()
self.after(0, show_dialog)
event.wait()
if "error" in answer:
raise answer["error"]
return answer["value"]
def confirm_large_email_size(self, size_value):
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 BundleCancelled as exc:
self.after(0, self.append_log, str(exc))
except Exception as exc:
self.after(0, messagebox.showerror, "Errore", str(exc))
self.after(0, self.append_log, f"ERRORE: {exc}")
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()