1484 lines
48 KiB
Python
1484 lines
48 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():
|
|
for executable in ("gswin64c", "gswin32c", "gs"):
|
|
path = shutil.which(executable)
|
|
if path:
|
|
return path
|
|
return None
|
|
|
|
|
|
def pdf_has_significant_text(pdf_path):
|
|
doc = fitz.open(pdf_path)
|
|
try:
|
|
if doc.page_count == 0:
|
|
return False
|
|
|
|
sample_indexes = sorted({0, doc.page_count // 2, doc.page_count - 1})
|
|
text_length = 0
|
|
for page_index in sample_indexes:
|
|
text_length += len(doc.load_page(page_index).get_text("text").strip())
|
|
|
|
return text_length >= 500
|
|
finally:
|
|
doc.close()
|
|
|
|
|
|
def optimized_pdf_result(source_file, optimized_path, profile, dpi, quality):
|
|
optimized_file = BundleFile(
|
|
path=optimized_path.resolve(),
|
|
arcname=reduced_pdf_arcname(source_file.arcname),
|
|
original_path=source_file.path,
|
|
)
|
|
|
|
return OptimizedPdf(
|
|
original=source_file,
|
|
optimized=optimized_file,
|
|
original_size=source_file.stat().st_size,
|
|
optimized_size=optimized_file.stat().st_size,
|
|
kind="pdf",
|
|
profile=profile,
|
|
dpi=dpi,
|
|
quality=quality,
|
|
)
|
|
|
|
|
|
def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None):
|
|
ghostscript = find_ghostscript()
|
|
if not ghostscript:
|
|
if log:
|
|
log("Ghostscript non trovato: salto ottimizzazione PDF avanzata.")
|
|
return None
|
|
|
|
optimized_root = Path(output_folder) / "_pdf_ridotti_email"
|
|
optimized_path = optimized_root / reduced_pdf_arcname(source_file.arcname)
|
|
optimized_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if log:
|
|
log(
|
|
f"Provo Ghostscript /{profile}: "
|
|
f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})"
|
|
)
|
|
|
|
command = [
|
|
ghostscript,
|
|
"-sDEVICE=pdfwrite",
|
|
"-dCompatibilityLevel=1.6",
|
|
f"-dPDFSETTINGS=/{profile}",
|
|
"-dNOPAUSE",
|
|
"-dQUIET",
|
|
"-dBATCH",
|
|
"-dDetectDuplicateImages=true",
|
|
"-dCompressFonts=true",
|
|
"-dSubsetFonts=true",
|
|
f"-sOutputFile={optimized_path}",
|
|
str(source_file.path),
|
|
]
|
|
|
|
completed = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
)
|
|
|
|
if completed.returncode != 0 or not optimized_path.exists():
|
|
if log:
|
|
error_text = (completed.stderr or completed.stdout or "errore sconosciuto").strip()
|
|
log(f"Ghostscript non ha prodotto un PDF valido: {error_text}")
|
|
optimized_path.unlink(missing_ok=True)
|
|
return None
|
|
|
|
return optimized_pdf_result(
|
|
source_file,
|
|
optimized_path,
|
|
profile=f"ghostscript_{profile}",
|
|
dpi=profile,
|
|
quality=None,
|
|
)
|
|
|
|
|
|
def optimize_pdf(source_file, output_folder, profile, dpi, quality, log=None):
|
|
optimized_root = Path(output_folder) / "_pdf_ridotti_email"
|
|
optimized_path = optimized_root / reduced_pdf_arcname(source_file.arcname)
|
|
optimized_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if log:
|
|
if dpi:
|
|
log(
|
|
f"Ottimizzo PDF a {dpi} DPI: "
|
|
f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})"
|
|
)
|
|
else:
|
|
log(
|
|
"Provo ottimizzazione PDF senza perdita: "
|
|
f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})"
|
|
)
|
|
|
|
source_doc = fitz.open(source_file.path)
|
|
try:
|
|
if dpi is None:
|
|
source_doc.save(
|
|
optimized_path,
|
|
garbage=4,
|
|
clean=True,
|
|
deflate=True,
|
|
deflate_images=True,
|
|
deflate_fonts=True,
|
|
use_objstms=True,
|
|
compression_effort=9,
|
|
)
|
|
optimized_file = BundleFile(
|
|
path=optimized_path.resolve(),
|
|
arcname=reduced_pdf_arcname(source_file.arcname),
|
|
original_path=source_file.path,
|
|
)
|
|
return OptimizedPdf(
|
|
original=source_file,
|
|
optimized=optimized_file,
|
|
original_size=source_file.stat().st_size,
|
|
optimized_size=optimized_file.stat().st_size,
|
|
kind="pdf",
|
|
profile=profile,
|
|
dpi=dpi,
|
|
quality=quality,
|
|
)
|
|
|
|
optimized_doc = fitz.open()
|
|
matrix = fitz.Matrix(dpi / 72, dpi / 72)
|
|
|
|
try:
|
|
for page_index, source_page in enumerate(source_doc, start=1):
|
|
if log:
|
|
log(f" pagina {page_index}/{source_doc.page_count}")
|
|
|
|
pixmap = source_page.get_pixmap(matrix=matrix, alpha=False)
|
|
image_bytes = pixmap.tobytes("jpeg", jpg_quality=quality)
|
|
page = optimized_doc.new_page(width=pixmap.width, height=pixmap.height)
|
|
page.insert_image(page.rect, stream=image_bytes)
|
|
|
|
optimized_doc.save(
|
|
optimized_path,
|
|
garbage=4,
|
|
deflate=True,
|
|
clean=True,
|
|
)
|
|
finally:
|
|
optimized_doc.close()
|
|
finally:
|
|
source_doc.close()
|
|
|
|
optimized_file = BundleFile(
|
|
path=optimized_path.resolve(),
|
|
arcname=reduced_pdf_arcname(source_file.arcname),
|
|
original_path=source_file.path,
|
|
)
|
|
|
|
return OptimizedPdf(
|
|
original=source_file,
|
|
optimized=optimized_file,
|
|
original_size=source_file.stat().st_size,
|
|
optimized_size=optimized_file.stat().st_size,
|
|
kind="pdf",
|
|
profile=profile,
|
|
dpi=dpi,
|
|
quality=quality,
|
|
)
|
|
|
|
|
|
def optimized_image_result(source_file, optimized_path, arcname, profile, max_side, quality):
|
|
optimized_file = BundleFile(
|
|
path=optimized_path.resolve(),
|
|
arcname=arcname,
|
|
original_path=source_file.path,
|
|
)
|
|
|
|
return OptimizedPdf(
|
|
original=source_file,
|
|
optimized=optimized_file,
|
|
original_size=source_file.stat().st_size,
|
|
optimized_size=optimized_file.stat().st_size,
|
|
kind="image",
|
|
profile=profile,
|
|
dpi=max_side,
|
|
quality=quality,
|
|
)
|
|
|
|
|
|
def normalize_image_for_format(image, target_format):
|
|
if image.mode in ("I;16", "I;16B", "I;16L"):
|
|
image = image.point(lambda value: value / 256).convert("L")
|
|
|
|
if target_format in {"JPEG", "BMP"} and (
|
|
image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
|
|
):
|
|
background = Image.new("RGB", image.size, "white")
|
|
alpha = image.convert("RGBA").getchannel("A")
|
|
background.paste(image.convert("RGBA"), mask=alpha)
|
|
return background
|
|
|
|
if target_format == "JPEG" and image.mode == "CMYK":
|
|
return image
|
|
|
|
if target_format in {"JPEG", "BMP", "WEBP"} and image.mode not in {"RGB", "L", "CMYK"}:
|
|
return image.convert("RGB")
|
|
|
|
if target_format == "PNG" and image.mode not in {"RGB", "RGBA", "L", "P"}:
|
|
return image.convert("RGB")
|
|
|
|
if target_format == "TIFF" and image.mode == "P":
|
|
return image.convert("RGB")
|
|
|
|
return image
|
|
|
|
|
|
def image_save_options(target_format, quality):
|
|
if target_format == "JPEG":
|
|
return {
|
|
"quality": quality,
|
|
"optimize": True,
|
|
"progressive": True,
|
|
}
|
|
|
|
if target_format == "PNG":
|
|
return {
|
|
"optimize": True,
|
|
"compress_level": 9,
|
|
}
|
|
|
|
if target_format == "TIFF":
|
|
return {
|
|
"compression": "tiff_adobe_deflate",
|
|
}
|
|
|
|
if target_format == "WEBP":
|
|
return {
|
|
"quality": quality,
|
|
"method": 6,
|
|
}
|
|
|
|
return {}
|
|
|
|
|
|
def image_target_format(source_path, convert_uncompressed=False):
|
|
suffix = source_path.suffix.lower()
|
|
if convert_uncompressed and suffix in {".bmp", ".tif", ".tiff"}:
|
|
return "JPEG"
|
|
if suffix in {".jpg", ".jpeg"}:
|
|
return "JPEG"
|
|
if suffix == ".png":
|
|
return "PNG"
|
|
if suffix in {".tif", ".tiff"}:
|
|
return "TIFF"
|
|
if suffix == ".webp":
|
|
return "WEBP"
|
|
if suffix == ".bmp":
|
|
return "BMP"
|
|
return None
|
|
|
|
|
|
def optimize_image(source_file, output_folder, profile, max_side, quality, log=None, convert_uncompressed=False):
|
|
optimized_root = Path(output_folder) / "_immagini_ridotte_email"
|
|
target_format = image_target_format(source_file.path, convert_uncompressed=convert_uncompressed)
|
|
if target_format is None:
|
|
raise ValueError(f"Formato immagine non supportato: {source_file.path.suffix}")
|
|
|
|
target_suffix = ".jpg" if target_format == "JPEG" else source_file.path.suffix.lower()
|
|
optimized_arcname = reduced_image_arcname(source_file.arcname, target_suffix=target_suffix)
|
|
optimized_path = optimized_root / optimized_arcname
|
|
optimized_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if log:
|
|
conversion_text = ""
|
|
if source_file.path.suffix.lower() != target_suffix:
|
|
conversion_text = f" -> {target_suffix}"
|
|
log(
|
|
f"Ottimizzo immagine {profile}{conversion_text}: {source_file.arcname} "
|
|
f"({format_mb(source_file.stat().st_size)})"
|
|
)
|
|
|
|
with Image.open(source_file.path) as image:
|
|
image = ImageOps.exif_transpose(image)
|
|
|
|
if getattr(image, "is_animated", False) and log:
|
|
log(" immagine animata: uso il primo frame per la copia email.")
|
|
image.seek(0)
|
|
|
|
image = normalize_image_for_format(image, target_format)
|
|
image.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
|
|
|
|
image.save(
|
|
optimized_path,
|
|
target_format,
|
|
**image_save_options(target_format, quality),
|
|
)
|
|
|
|
return optimized_image_result(source_file, optimized_path, optimized_arcname, profile, max_side, quality)
|
|
|
|
|
|
def try_optimize_image_for_limit(source_file, output_folder, max_zip_size, log=None, convert_uncompressed=False):
|
|
original_size = source_file.stat().st_size
|
|
|
|
for profile_name, max_side, quality in IMAGE_OPTIMIZE_PROFILES:
|
|
optimized = optimize_image(
|
|
source_file,
|
|
output_folder,
|
|
profile_name,
|
|
max_side,
|
|
quality,
|
|
log=log,
|
|
convert_uncompressed=convert_uncompressed,
|
|
)
|
|
|
|
if optimized.optimized_size >= original_size:
|
|
if log:
|
|
log(
|
|
"Scarto questa copia: non riduce l'immagine "
|
|
f"({format_mb(original_size)} -> {format_mb(optimized.optimized_size)})."
|
|
)
|
|
optimized.optimized.path.unlink(missing_ok=True)
|
|
continue
|
|
|
|
with tempfile.TemporaryDirectory() as temp_folder:
|
|
zip_size = compressed_zip_size([optimized.optimized], temp_folder)
|
|
|
|
if zip_size <= max_zip_size:
|
|
return optimized
|
|
|
|
if log:
|
|
log(
|
|
f"La copia immagine e ancora troppo grande: "
|
|
f"{format_mb(zip_size)} nello ZIP."
|
|
)
|
|
optimized.optimized.path.unlink(missing_ok=True)
|
|
|
|
return None
|
|
|
|
|
|
def optimized_office_result(source_file, optimized_path):
|
|
optimized_file = BundleFile(
|
|
path=optimized_path.resolve(),
|
|
arcname=reduced_office_arcname(source_file.arcname),
|
|
original_path=source_file.path,
|
|
)
|
|
|
|
return OptimizedPdf(
|
|
original=source_file,
|
|
optimized=optimized_file,
|
|
original_size=source_file.stat().st_size,
|
|
optimized_size=optimized_file.stat().st_size,
|
|
kind="office",
|
|
profile="office_pdf",
|
|
dpi=None,
|
|
quality=None,
|
|
)
|
|
|
|
|
|
def convert_office_to_pdf(source_file, output_folder, log=None):
|
|
suffix = source_file.path.suffix.lower()
|
|
output_root = Path(output_folder) / "_office_convertiti_pdf"
|
|
pdf_path = output_root / reduced_office_arcname(source_file.arcname)
|
|
pdf_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if log:
|
|
log(f"Converto Office in PDF: {source_file.arcname} ({format_mb(source_file.stat().st_size)})")
|
|
|
|
try:
|
|
import pythoncom
|
|
import win32com.client
|
|
except Exception as exc:
|
|
if log:
|
|
log(f"Conversione Office non disponibile: {exc}")
|
|
return None
|
|
|
|
pythoncom.CoInitialize()
|
|
try:
|
|
if suffix in {".doc", ".docx"}:
|
|
word = None
|
|
document = None
|
|
try:
|
|
word = win32com.client.DispatchEx("Word.Application")
|
|
word.Visible = False
|
|
word.DisplayAlerts = 0
|
|
document = word.Documents.Open(str(source_file.path), ReadOnly=True)
|
|
document.SaveAs(str(pdf_path), FileFormat=17)
|
|
finally:
|
|
if document is not None:
|
|
document.Close(False)
|
|
if word is not None:
|
|
word.Quit()
|
|
|
|
elif suffix in {".xls", ".xlsx"}:
|
|
excel = None
|
|
workbook = None
|
|
try:
|
|
excel = win32com.client.DispatchEx("Excel.Application")
|
|
excel.Visible = False
|
|
excel.DisplayAlerts = False
|
|
workbook = excel.Workbooks.Open(str(source_file.path), ReadOnly=True)
|
|
workbook.ExportAsFixedFormat(0, str(pdf_path))
|
|
finally:
|
|
if workbook is not None:
|
|
workbook.Close(False)
|
|
if excel is not None:
|
|
excel.Quit()
|
|
|
|
elif suffix in {".ppt", ".pptx"}:
|
|
powerpoint = None
|
|
presentation = None
|
|
try:
|
|
powerpoint = win32com.client.DispatchEx("PowerPoint.Application")
|
|
presentation = powerpoint.Presentations.Open(
|
|
str(source_file.path),
|
|
WithWindow=False,
|
|
)
|
|
presentation.SaveAs(str(pdf_path), 32)
|
|
finally:
|
|
if presentation is not None:
|
|
presentation.Close()
|
|
if powerpoint is not None:
|
|
powerpoint.Quit()
|
|
else:
|
|
return None
|
|
except Exception as exc:
|
|
if log:
|
|
log(f"Conversione Office fallita: {exc}")
|
|
pdf_path.unlink(missing_ok=True)
|
|
return None
|
|
finally:
|
|
pythoncom.CoUninitialize()
|
|
|
|
if not pdf_path.exists():
|
|
if log:
|
|
log("Conversione Office fallita: PDF non creato.")
|
|
return None
|
|
|
|
return optimized_office_result(source_file, pdf_path)
|
|
|
|
|
|
def try_convert_office_for_limit(source_file, output_folder, max_zip_size, log=None):
|
|
converted = convert_office_to_pdf(source_file, output_folder, log=log)
|
|
if converted is None:
|
|
return None
|
|
|
|
with tempfile.TemporaryDirectory() as temp_folder:
|
|
zip_size = compressed_zip_size([converted.optimized], temp_folder)
|
|
|
|
if zip_size <= max_zip_size:
|
|
return converted
|
|
|
|
if log:
|
|
log(f"Il PDF convertito e ancora troppo grande: {format_mb(zip_size)} nello ZIP.")
|
|
|
|
converted.optimized.path.unlink(missing_ok=True)
|
|
return None
|
|
|
|
|
|
def try_optimize_pdf_for_limit(source_file, output_folder, max_zip_size, log=None):
|
|
original_size = source_file.stat().st_size
|
|
skip_raster_profiles = pdf_has_significant_text(source_file.path)
|
|
|
|
for profile_name, dpi, quality in PDF_OPTIMIZE_PROFILES:
|
|
if isinstance(dpi, int) and skip_raster_profiles:
|
|
if log:
|
|
log(
|
|
"Il PDF contiene testo selezionabile: salto la ricodifica a immagini "
|
|
"per evitare file piu grandi e perdita di qualita."
|
|
)
|
|
break
|
|
|
|
if log:
|
|
log(f"Provo profilo PDF '{profile_name}'.")
|
|
|
|
if isinstance(dpi, str):
|
|
optimized = optimize_pdf_with_ghostscript(source_file, output_folder, dpi, log=log)
|
|
if optimized is None:
|
|
continue
|
|
else:
|
|
optimized = optimize_pdf(source_file, output_folder, profile_name, dpi, quality, log=log)
|
|
|
|
if optimized.optimized_size >= original_size:
|
|
if log:
|
|
log(
|
|
"Scarto questa copia: non riduce il PDF "
|
|
f"({format_mb(original_size)} -> {format_mb(optimized.optimized_size)})."
|
|
)
|
|
optimized.optimized.path.unlink(missing_ok=True)
|
|
continue
|
|
|
|
with tempfile.TemporaryDirectory() as temp_folder:
|
|
zip_size = compressed_zip_size([optimized.optimized], temp_folder)
|
|
|
|
if zip_size <= max_zip_size:
|
|
return optimized
|
|
|
|
if log:
|
|
log(
|
|
f"La copia ridotta e ancora troppo grande: "
|
|
f"{format_mb(zip_size)} nello ZIP."
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def split_into_groups(
|
|
files,
|
|
output_folder,
|
|
max_zip_size,
|
|
log=None,
|
|
confirm_optimization=None,
|
|
convert_uncompressed_images=False,
|
|
convert_office_to_pdf_enabled=False,
|
|
):
|
|
groups = []
|
|
current_group = []
|
|
optimized_reports = []
|
|
|
|
# Larger files first usually reduces the number of packages.
|
|
files = sorted(files, key=lambda f: f.stat().st_size, reverse=True)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_folder:
|
|
for index, file_path in enumerate(files, start=1):
|
|
if log:
|
|
log(f"Analisi file {index}/{len(files)}: {file_path.display_name}")
|
|
|
|
candidate_group = current_group + [file_path]
|
|
candidate_size = compressed_zip_size(candidate_group, temp_folder)
|
|
|
|
if candidate_size <= max_zip_size:
|
|
current_group = candidate_group
|
|
continue
|
|
|
|
single_size = compressed_zip_size([file_path], temp_folder)
|
|
if single_size <= max_zip_size:
|
|
if current_group:
|
|
groups.append(current_group)
|
|
current_group = [file_path]
|
|
continue
|
|
|
|
replacement = None
|
|
if file_path.path.suffix.lower() == ".pdf":
|
|
replacement = try_optimize_pdf_for_limit(
|
|
file_path,
|
|
output_folder,
|
|
max_zip_size,
|
|
log=log,
|
|
)
|
|
|
|
elif file_path.path.suffix.lower() in IMAGE_SUFFIXES:
|
|
replacement = try_optimize_image_for_limit(
|
|
file_path,
|
|
output_folder,
|
|
max_zip_size,
|
|
log=log,
|
|
convert_uncompressed=convert_uncompressed_images,
|
|
)
|
|
elif convert_office_to_pdf_enabled and file_path.path.suffix.lower() in OFFICE_SUFFIXES:
|
|
replacement = try_convert_office_for_limit(
|
|
file_path,
|
|
output_folder,
|
|
max_zip_size,
|
|
log=log,
|
|
)
|
|
|
|
if replacement and replacement.kind != "office" and confirm_optimization:
|
|
accepted = confirm_optimization(replacement)
|
|
if not accepted:
|
|
if log:
|
|
log(f"Ottimizzazione rifiutata: {file_path.arcname}")
|
|
replacement = None
|
|
|
|
if replacement:
|
|
optimized_reports.append(replacement)
|
|
replacement_group = current_group + [replacement.optimized]
|
|
replacement_group_size = compressed_zip_size(replacement_group, temp_folder)
|
|
|
|
if replacement_group_size <= max_zip_size:
|
|
current_group = replacement_group
|
|
else:
|
|
if current_group:
|
|
groups.append(current_group)
|
|
current_group = [replacement.optimized]
|
|
|
|
if log:
|
|
labels = {
|
|
"pdf": "PDF alleggerito accettato",
|
|
"image": "Immagine alleggerita accettata",
|
|
"office": "File Office convertito accettato",
|
|
}
|
|
item_label = labels.get(replacement.kind, "File ottimizzato accettato")
|
|
log(
|
|
f"{item_label}: {file_path.arcname} -> "
|
|
f"{replacement.optimized.arcname} "
|
|
f"({format_mb(replacement.original_size)} -> "
|
|
f"{format_mb(replacement.optimized_size)})"
|
|
)
|
|
else:
|
|
if current_group:
|
|
groups.append(current_group)
|
|
current_group = []
|
|
if log:
|
|
log(
|
|
"ATTENZIONE: il file supera il limite anche da solo "
|
|
f"e verra inserito in un pacchetto fuori limite: {file_path.arcname} "
|
|
f"({format_mb(single_size)} nello ZIP)"
|
|
)
|
|
groups.append([file_path])
|
|
|
|
if current_group:
|
|
groups.append(current_group)
|
|
|
|
return groups, optimized_reports
|
|
|
|
|
|
def bundle_folder(
|
|
source_folder,
|
|
output_folder,
|
|
max_size_mb,
|
|
log=None,
|
|
confirm_optimization=None,
|
|
convert_uncompressed_images=False,
|
|
convert_office_to_pdf_enabled=False,
|
|
):
|
|
source_folder = Path(source_folder).resolve()
|
|
output_folder = Path(output_folder).resolve()
|
|
max_zip_size = mb_to_bytes(max_size_mb)
|
|
|
|
if not source_folder.exists() or not source_folder.is_dir():
|
|
raise ValueError("La cartella sorgente indicata non esiste.")
|
|
|
|
if max_zip_size <= 0:
|
|
raise ValueError("La dimensione massima deve essere maggiore di zero.")
|
|
|
|
if output_folder == source_folder:
|
|
raise ValueError("La cartella di destinazione deve essere diversa dalla sorgente.")
|
|
|
|
output_folder.mkdir(parents=True, exist_ok=True)
|
|
|
|
for old_zip in output_folder.glob("pacchetto_*.zip"):
|
|
old_zip.unlink()
|
|
|
|
optimized_root = output_folder / "_pdf_ridotti_email"
|
|
if optimized_root.exists():
|
|
for old_pdf in optimized_root.rglob("*.pdf"):
|
|
old_pdf.unlink()
|
|
|
|
optimized_image_root = output_folder / "_immagini_ridotte_email"
|
|
if optimized_image_root.exists():
|
|
for old_image in optimized_image_root.rglob("*"):
|
|
if old_image.is_file():
|
|
old_image.unlink()
|
|
|
|
office_pdf_root = output_folder / "_office_convertiti_pdf"
|
|
if office_pdf_root.exists():
|
|
for old_pdf in office_pdf_root.rglob("*.pdf"):
|
|
old_pdf.unlink()
|
|
|
|
if log:
|
|
log(f"Sorgente: {source_folder}")
|
|
log(f"Destinazione: {output_folder}")
|
|
log(f"Limite pacchetto: {float(max_size_mb):.2f} MB")
|
|
log(
|
|
"Conversione BMP/TIFF in JPEG: "
|
|
+ ("attiva" if convert_uncompressed_images else "disattiva")
|
|
)
|
|
log(
|
|
"Conversione Office in PDF: "
|
|
+ ("attiva" if convert_office_to_pdf_enabled else "disattiva")
|
|
)
|
|
|
|
files = collect_files(source_folder, exclude_folder=output_folder)
|
|
|
|
if not files:
|
|
raise ValueError("Nessun file trovato nella cartella sorgente.")
|
|
|
|
if log:
|
|
total_size = sum(f.stat().st_size for f in files)
|
|
log(f"Trovati {len(files)} file ({format_mb(total_size)} originali).")
|
|
|
|
groups, optimized_reports = split_into_groups(
|
|
files,
|
|
output_folder,
|
|
max_zip_size,
|
|
log=log,
|
|
confirm_optimization=confirm_optimization,
|
|
convert_uncompressed_images=convert_uncompressed_images,
|
|
convert_office_to_pdf_enabled=convert_office_to_pdf_enabled,
|
|
)
|
|
results = []
|
|
|
|
for i, group in enumerate(groups, start=1):
|
|
zip_name = f"pacchetto_{i:03d}.zip"
|
|
zip_path = output_folder / zip_name
|
|
create_zip(zip_path, group)
|
|
|
|
original_size = sum(f.stat().st_size for f in group)
|
|
zip_size = zip_path.stat().st_size
|
|
results.append((zip_path, len(group), original_size, zip_size))
|
|
|
|
if log:
|
|
log(
|
|
f"Creato {zip_name} - "
|
|
f"{len(group)} file - "
|
|
f"originale {format_mb(original_size)} - "
|
|
f"zip {format_mb(zip_size)}"
|
|
)
|
|
|
|
if optimized_reports and log:
|
|
log("")
|
|
log("File ottimizzati:")
|
|
for report in optimized_reports:
|
|
profile_text = optimization_label(report)
|
|
item_label = "PDF" if report.kind == "pdf" else "immagine"
|
|
log(
|
|
f"- {item_label}: {report.original.arcname} -> {report.optimized.arcname} "
|
|
f"({format_mb(report.original_size)} -> "
|
|
f"{format_mb(report.optimized_size)}, {profile_text})"
|
|
)
|
|
|
|
if log:
|
|
log("")
|
|
log(f"Finito. ZIP creati in: {output_folder}")
|
|
|
|
return results
|
|
|
|
|
|
def pdf_page_count(pdf_path):
|
|
doc = fitz.open(pdf_path)
|
|
try:
|
|
return doc.page_count
|
|
finally:
|
|
doc.close()
|
|
|
|
|
|
def render_pdf_preview(pdf_path, page_index=0, max_size=(360, 480)):
|
|
doc = fitz.open(pdf_path)
|
|
try:
|
|
page_index = max(0, min(page_index, doc.page_count - 1))
|
|
page = doc.load_page(page_index)
|
|
pixmap = page.get_pixmap(matrix=fitz.Matrix(1.2, 1.2), alpha=False)
|
|
image = Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
|
|
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
return image
|
|
finally:
|
|
doc.close()
|
|
|
|
|
|
def render_image_preview(image_path, max_size=(360, 480)):
|
|
with Image.open(image_path) as image:
|
|
image = ImageOps.exif_transpose(image)
|
|
if getattr(image, "is_animated", False):
|
|
image.seek(0)
|
|
image = normalize_image_for_format(image, "JPEG")
|
|
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
return image.copy()
|
|
|
|
|
|
def render_preview(report, path, page_index=0):
|
|
if report.kind == "pdf":
|
|
return render_pdf_preview(path, page_index)
|
|
return render_image_preview(path)
|
|
|
|
|
|
class OptimizationDialog(ctk.CTkToplevel):
|
|
def __init__(self, parent, report):
|
|
super().__init__(parent)
|
|
self.report = report
|
|
self.result = False
|
|
self.page_index = 0
|
|
self.page_count = 1
|
|
if report.kind == "pdf":
|
|
self.page_count = min(
|
|
pdf_page_count(report.original.path),
|
|
pdf_page_count(report.optimized.path),
|
|
)
|
|
self.title("File troppo grande")
|
|
self.geometry("900x720")
|
|
self.minsize(780, 620)
|
|
self.transient(parent)
|
|
self.grab_set()
|
|
|
|
self.grid_columnconfigure((0, 1), weight=1)
|
|
self.grid_rowconfigure(2, weight=1)
|
|
|
|
item_label = "PDF" if report.kind == "pdf" else "immagine"
|
|
title = (
|
|
f"{report.original.arcname.name} supera il limite. "
|
|
"Ho creato una copia alleggerita per email."
|
|
)
|
|
ctk.CTkLabel(self, text=title, wraplength=780, font=ctk.CTkFont(size=16, weight="bold")).grid(
|
|
row=0, column=0, columnspan=2, padx=18, pady=(18, 10), sticky="w"
|
|
)
|
|
|
|
pager = ctk.CTkFrame(self, fg_color="transparent")
|
|
pager.grid(row=1, column=0, columnspan=2, padx=18, pady=(0, 8), sticky="ew")
|
|
pager.grid_columnconfigure(1, weight=1)
|
|
|
|
self.prev_button = ctk.CTkButton(pager, text="<", width=44, command=self.previous_page)
|
|
self.prev_button.grid(row=0, column=0, padx=(0, 10))
|
|
|
|
self.page_label = ctk.CTkLabel(pager, text="")
|
|
self.page_label.grid(row=0, column=1)
|
|
|
|
self.next_button = ctk.CTkButton(pager, text=">", width=44, command=self.next_page)
|
|
self.next_button.grid(row=0, column=2, padx=(10, 0))
|
|
if report.kind != "pdf":
|
|
self.prev_button.grid_remove()
|
|
self.next_button.grid_remove()
|
|
|
|
self.original_image = None
|
|
self.optimized_image = None
|
|
|
|
self._build_preview_column(
|
|
0,
|
|
"Originale",
|
|
format_mb(report.original_size),
|
|
)
|
|
self._build_preview_column(
|
|
1,
|
|
f"Ridotto ({optimization_label(report)})",
|
|
format_mb(report.optimized_size),
|
|
)
|
|
self.update_previews()
|
|
|
|
buttons = ctk.CTkFrame(self, fg_color="transparent")
|
|
buttons.grid(row=3, column=0, columnspan=2, padx=18, pady=(10, 18), sticky="e")
|
|
|
|
ctk.CTkButton(buttons, text="Rifiuta", width=110, command=self.reject).grid(
|
|
row=0, column=0, padx=(0, 10)
|
|
)
|
|
ctk.CTkButton(buttons, text="Accetta copia ridotta", width=180, command=self.accept).grid(
|
|
row=0, column=1
|
|
)
|
|
|
|
self.protocol("WM_DELETE_WINDOW", self.reject)
|
|
|
|
def _build_preview_column(self, column, label, size_text):
|
|
frame = ctk.CTkFrame(self, corner_radius=8)
|
|
frame.grid(row=2, column=column, padx=(18 if column == 0 else 9, 18 if column == 1 else 9), pady=8, sticky="nsew")
|
|
frame.grid_columnconfigure(0, weight=1)
|
|
frame.grid_rowconfigure(1, weight=1)
|
|
|
|
ctk.CTkLabel(frame, text=f"{label} - {size_text}", font=ctk.CTkFont(weight="bold")).grid(
|
|
row=0, column=0, padx=12, pady=(12, 8), sticky="w"
|
|
)
|
|
image_label = ctk.CTkLabel(frame, text="")
|
|
image_label.grid(row=1, column=0, padx=12, pady=(0, 12), sticky="n")
|
|
|
|
if column == 0:
|
|
self.original_image_label = image_label
|
|
else:
|
|
self.optimized_image_label = image_label
|
|
|
|
def update_previews(self):
|
|
self.original_image = ImageTk.PhotoImage(
|
|
render_preview(self.report, self.report.original.path, self.page_index)
|
|
)
|
|
self.optimized_image = ImageTk.PhotoImage(
|
|
render_preview(self.report, self.report.optimized.path, self.page_index)
|
|
)
|
|
self.original_image_label.configure(image=self.original_image)
|
|
self.optimized_image_label.configure(image=self.optimized_image)
|
|
if self.report.kind == "pdf":
|
|
self.page_label.configure(text=f"Pagina {self.page_index + 1} / {self.page_count}")
|
|
else:
|
|
self.page_label.configure(text=f"{optimization_label(self.report)}")
|
|
self.prev_button.configure(state="normal" if self.page_index > 0 else "disabled")
|
|
self.next_button.configure(
|
|
state="normal" if self.page_index < self.page_count - 1 else "disabled"
|
|
)
|
|
|
|
def previous_page(self):
|
|
if self.page_index > 0:
|
|
self.page_index -= 1
|
|
self.update_previews()
|
|
|
|
def next_page(self):
|
|
if self.page_index < self.page_count - 1:
|
|
self.page_index += 1
|
|
self.update_previews()
|
|
|
|
def accept(self):
|
|
self.result = True
|
|
self.destroy()
|
|
|
|
def reject(self):
|
|
self.result = False
|
|
self.destroy()
|
|
|
|
|
|
class LargeEmailSizeDialog(ctk.CTkToplevel):
|
|
def __init__(self, parent, size_value):
|
|
super().__init__(parent)
|
|
self.result = None
|
|
self.title("Dimensione allegati email")
|
|
self.geometry("560x300")
|
|
self.resizable(False, False)
|
|
self.transient(parent)
|
|
self.grab_set()
|
|
|
|
self.grid_columnconfigure(0, weight=1)
|
|
|
|
ctk.CTkLabel(
|
|
self,
|
|
text=f"Hai impostato pacchetti da {size_value:.1f} MB.",
|
|
font=ctk.CTkFont(size=16, weight="bold"),
|
|
).grid(row=0, column=0, padx=22, pady=(22, 10), sticky="w")
|
|
|
|
ctk.CTkLabel(
|
|
self,
|
|
text=(
|
|
"Questa dimensione supera 18 MB. Molti provider di posta elettronica "
|
|
"applicano limiti agli allegati, e la codifica email puo aumentare "
|
|
"la dimensione effettiva del messaggio."
|
|
),
|
|
wraplength=510,
|
|
justify="left",
|
|
).grid(row=1, column=0, padx=22, pady=(0, 18), sticky="w")
|
|
|
|
buttons = ctk.CTkFrame(self, fg_color="transparent")
|
|
buttons.grid(row=2, column=0, padx=22, pady=(8, 22), sticky="e")
|
|
|
|
ctk.CTkButton(buttons, text="Annulla", width=100, command=self.cancel).grid(
|
|
row=0, column=0, padx=(0, 10)
|
|
)
|
|
ctk.CTkButton(buttons, text="Riduci a 18 MB", width=130, command=self.reduce).grid(
|
|
row=0, column=1, padx=(0, 10)
|
|
)
|
|
ctk.CTkButton(buttons, text="Mantieni", width=110, command=self.keep).grid(
|
|
row=0, column=2
|
|
)
|
|
|
|
self.protocol("WM_DELETE_WINDOW", self.cancel)
|
|
self.after(100, self.lift)
|
|
self.after(120, self.focus_force)
|
|
|
|
def keep(self):
|
|
self.result = "keep"
|
|
self.destroy()
|
|
|
|
def reduce(self):
|
|
self.result = "reduce"
|
|
self.destroy()
|
|
|
|
def cancel(self):
|
|
self.result = "cancel"
|
|
self.destroy()
|
|
|
|
|
|
class ZipBundlerApp(ctk.CTk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
self.title("ZipBundler versione 1.0")
|
|
self.geometry("900x640")
|
|
self.minsize(820, 580)
|
|
|
|
ctk.set_appearance_mode("system")
|
|
ctk.set_default_color_theme("green")
|
|
|
|
self.source_var = ctk.StringVar()
|
|
self.output_var = ctk.StringVar()
|
|
self.size_var = ctk.StringVar(value=str(DEFAULT_MAX_ZIP_SIZE_MB))
|
|
self.allow_large_email_var = ctk.BooleanVar(value=False)
|
|
self.convert_uncompressed_var = ctk.BooleanVar(value=False)
|
|
self.convert_office_var = ctk.BooleanVar(value=False)
|
|
self.is_running = False
|
|
|
|
self.grid_columnconfigure(0, weight=1)
|
|
self.grid_rowconfigure(2, weight=1)
|
|
|
|
self._build_header()
|
|
self._build_form()
|
|
self._build_log()
|
|
self.size_var.trace_add("write", lambda *_: self.update_size_warning())
|
|
self.update_size_warning()
|
|
|
|
def _build_header(self):
|
|
header = ctk.CTkFrame(self, fg_color="transparent")
|
|
header.grid(row=0, column=0, padx=22, pady=(18, 8), sticky="ew")
|
|
header.grid_columnconfigure(0, weight=1)
|
|
|
|
ctk.CTkLabel(
|
|
header,
|
|
text="ZipBundler",
|
|
font=ctk.CTkFont(size=24, weight="bold"),
|
|
).grid(row=0, column=0, sticky="w")
|
|
|
|
ctk.CTkLabel(
|
|
header,
|
|
text="Pacchetti ZIP entro soglia, con riduzione guidata di PDF, immagini e file Office.",
|
|
text_color=("gray35", "gray72"),
|
|
).grid(row=1, column=0, pady=(2, 0), sticky="w")
|
|
|
|
def _build_form(self):
|
|
form = ctk.CTkFrame(self, corner_radius=8)
|
|
form.grid(row=1, column=0, padx=22, pady=(0, 12), sticky="ew")
|
|
form.grid_columnconfigure(1, weight=1)
|
|
|
|
ctk.CTkLabel(form, text="Cartella sorgente", font=ctk.CTkFont(weight="bold")).grid(
|
|
row=0, column=0, padx=(16, 10), pady=(16, 8), sticky="w"
|
|
)
|
|
ctk.CTkEntry(form, textvariable=self.source_var).grid(
|
|
row=0, column=1, padx=0, pady=(16, 8), sticky="ew"
|
|
)
|
|
ctk.CTkButton(form, text="Sfoglia", width=96, command=self.pick_source).grid(
|
|
row=0, column=2, padx=16, pady=(16, 8)
|
|
)
|
|
|
|
ctk.CTkLabel(form, text="Destinazione ZIP", font=ctk.CTkFont(weight="bold")).grid(
|
|
row=1, column=0, padx=(16, 10), pady=8, sticky="w"
|
|
)
|
|
ctk.CTkEntry(form, textvariable=self.output_var).grid(
|
|
row=1, column=1, padx=0, pady=8, sticky="ew"
|
|
)
|
|
ctk.CTkButton(form, text="Sfoglia", width=96, command=self.pick_output).grid(
|
|
row=1, column=2, padx=16, pady=8
|
|
)
|
|
|
|
ctk.CTkLabel(
|
|
form,
|
|
text="Dimensione max pacchetto (MB)",
|
|
font=ctk.CTkFont(weight="bold"),
|
|
).grid(
|
|
row=2, column=0, padx=(16, 10), pady=(8, 16), sticky="w"
|
|
)
|
|
size_spinbox = ctk.CTkFrame(form, fg_color="transparent")
|
|
size_spinbox.grid(row=2, column=1, padx=0, pady=(8, 16), sticky="w")
|
|
|
|
ctk.CTkButton(size_spinbox, text="-", width=34, command=lambda: self.step_size(-1)).grid(
|
|
row=0, column=0, padx=(0, 6)
|
|
)
|
|
ctk.CTkEntry(size_spinbox, textvariable=self.size_var, width=84, justify="center").grid(
|
|
row=0, column=1
|
|
)
|
|
ctk.CTkButton(size_spinbox, text="+", width=34, command=lambda: self.step_size(1)).grid(
|
|
row=0, column=2, padx=(6, 0)
|
|
)
|
|
|
|
self.large_size_panel = ctk.CTkFrame(form, corner_radius=8, fg_color="#fff4e5")
|
|
self.large_size_panel.grid(
|
|
row=3, column=0, columnspan=3, padx=16, pady=(0, 14), sticky="ew"
|
|
)
|
|
self.large_size_panel.grid_columnconfigure(0, weight=1)
|
|
|
|
self.large_size_warning = ctk.CTkLabel(
|
|
self.large_size_panel,
|
|
text=(
|
|
"Oltre 18 MB gli allegati possono essere rifiutati da molti provider email."
|
|
),
|
|
text_color="#7a3e00",
|
|
wraplength=520,
|
|
justify="left",
|
|
)
|
|
self.large_size_warning.grid(row=0, column=0, padx=12, pady=(10, 4), sticky="w")
|
|
|
|
self.large_size_checkbox = ctk.CTkCheckBox(
|
|
self.large_size_panel,
|
|
text="Accetto questo limite",
|
|
variable=self.allow_large_email_var,
|
|
text_color="#7a3e00",
|
|
)
|
|
self.large_size_checkbox.grid(row=1, column=0, padx=12, pady=(0, 10), sticky="w")
|
|
|
|
ctk.CTkButton(
|
|
self.large_size_panel,
|
|
text="Riduci a 18 MB",
|
|
width=120,
|
|
command=lambda: self.size_var.set("18"),
|
|
fg_color="#a15c00",
|
|
hover_color="#7a3e00",
|
|
).grid(row=0, column=1, rowspan=2, padx=12, pady=10, sticky="e")
|
|
|
|
options = ctk.CTkFrame(form, fg_color="transparent")
|
|
options.grid(row=5, column=0, columnspan=2, padx=16, pady=(0, 16), sticky="w")
|
|
|
|
ctk.CTkLabel(
|
|
options,
|
|
text="Opzioni conversione",
|
|
font=ctk.CTkFont(weight="bold"),
|
|
text_color=("gray30", "gray75"),
|
|
).grid(row=0, column=0, pady=(0, 8), sticky="w")
|
|
|
|
self.convert_checkbox = ctk.CTkCheckBox(
|
|
options,
|
|
text="Converti formati non compressi in JPEG (es. BMP/TIFF)",
|
|
variable=self.convert_uncompressed_var,
|
|
)
|
|
self.convert_checkbox.grid(row=1, column=0, pady=(0, 8), sticky="w")
|
|
|
|
self.office_checkbox = ctk.CTkCheckBox(
|
|
options,
|
|
text="Converti file Office grandi in PDF (es. DOCX -> PDF)",
|
|
variable=self.convert_office_var,
|
|
)
|
|
self.office_checkbox.grid(row=2, column=0, sticky="w")
|
|
|
|
self.start_button = ctk.CTkButton(
|
|
form,
|
|
text="Crea pacchetti ZIP",
|
|
width=160,
|
|
height=36,
|
|
command=self.start_bundle,
|
|
)
|
|
self.start_button.grid(row=6, column=2, padx=16, pady=(0, 16))
|
|
|
|
def _build_log(self):
|
|
body = ctk.CTkFrame(self, corner_radius=8)
|
|
body.grid(row=2, column=0, padx=22, pady=(0, 22), sticky="nsew")
|
|
body.grid_columnconfigure(0, weight=1)
|
|
body.grid_rowconfigure(2, weight=1)
|
|
|
|
ctk.CTkLabel(
|
|
body,
|
|
text="Avanzamento",
|
|
font=ctk.CTkFont(size=15, weight="bold"),
|
|
).grid(row=0, column=0, padx=16, pady=(14, 8), sticky="w")
|
|
|
|
self.progress = ctk.CTkProgressBar(body, mode="indeterminate")
|
|
self.progress.grid(row=1, column=0, padx=16, pady=(0, 10), sticky="ew")
|
|
self.progress.set(0)
|
|
|
|
self.log_box = ctk.CTkTextbox(body, wrap="word")
|
|
self.log_box.grid(row=2, column=0, padx=16, pady=(0, 16), sticky="nsew")
|
|
self.log_box.configure(state="disabled")
|
|
|
|
def pick_source(self):
|
|
folder = filedialog.askdirectory(title="Scegli la cartella da comprimere")
|
|
if not folder:
|
|
return
|
|
|
|
self.source_var.set(folder)
|
|
if not self.output_var.get():
|
|
source = Path(folder)
|
|
self.output_var.set(str(source.parent / f"{source.name}_zip_{self.size_var.get()}mb"))
|
|
|
|
def pick_output(self):
|
|
folder = filedialog.askdirectory(title="Scegli dove salvare i pacchetti ZIP")
|
|
if folder:
|
|
self.output_var.set(folder)
|
|
|
|
def append_log(self, text):
|
|
self.log_box.configure(state="normal")
|
|
self.log_box.insert("end", text + "\n")
|
|
self.log_box.see("end")
|
|
self.log_box.configure(state="disabled")
|
|
|
|
def set_running(self, running):
|
|
self.is_running = running
|
|
self.start_button.configure(state="disabled" if running else "normal")
|
|
if running:
|
|
self.progress.start()
|
|
else:
|
|
self.progress.stop()
|
|
self.progress.set(0)
|
|
|
|
def step_size(self, delta):
|
|
try:
|
|
current = float(self.size_var.get().strip().replace(",", "."))
|
|
except ValueError:
|
|
current = DEFAULT_MAX_ZIP_SIZE_MB
|
|
|
|
next_value = max(1, current + delta)
|
|
if next_value.is_integer():
|
|
self.size_var.set(str(int(next_value)))
|
|
else:
|
|
self.size_var.set(f"{next_value:.1f}")
|
|
|
|
def update_size_warning(self):
|
|
try:
|
|
size_value = float(self.size_var.get().strip().replace(",", "."))
|
|
except ValueError:
|
|
size_value = DEFAULT_MAX_ZIP_SIZE_MB
|
|
|
|
if size_value > 18:
|
|
self.large_size_warning.configure(
|
|
text="Oltre 18 MB gli allegati possono essere rifiutati da molti provider email."
|
|
)
|
|
self.large_size_panel.grid()
|
|
else:
|
|
self.allow_large_email_var.set(False)
|
|
self.large_size_panel.grid_remove()
|
|
|
|
def confirm_optimization(self, report):
|
|
event = threading.Event()
|
|
answer = {"value": False}
|
|
|
|
def show_dialog():
|
|
dialog = OptimizationDialog(self, report)
|
|
self.wait_window(dialog)
|
|
answer["value"] = dialog.result
|
|
event.set()
|
|
|
|
self.after(0, show_dialog)
|
|
event.wait()
|
|
return answer["value"]
|
|
|
|
def confirm_large_email_size(self, size_value):
|
|
dialog = LargeEmailSizeDialog(self, size_value)
|
|
self.wait_window(dialog)
|
|
return dialog.result
|
|
|
|
def start_bundle(self):
|
|
if self.is_running:
|
|
return
|
|
|
|
source = self.source_var.get().strip()
|
|
output = self.output_var.get().strip()
|
|
size = self.size_var.get().strip().replace(",", ".")
|
|
convert_uncompressed = self.convert_uncompressed_var.get()
|
|
convert_office = self.convert_office_var.get()
|
|
|
|
if not source:
|
|
messagebox.showerror("Dato mancante", "Scegli la cartella sorgente.")
|
|
return
|
|
|
|
if not output:
|
|
messagebox.showerror("Dato mancante", "Scegli la cartella di destinazione.")
|
|
return
|
|
|
|
try:
|
|
size_value = float(size)
|
|
if size_value <= 0:
|
|
raise ValueError
|
|
except ValueError:
|
|
messagebox.showerror(
|
|
"Valore non valido",
|
|
"Inserisci una dimensione massima valida, per esempio 20.",
|
|
)
|
|
return
|
|
|
|
if size_value > 18 and not self.allow_large_email_var.get():
|
|
self.large_size_warning.configure(
|
|
text=(
|
|
"Spunta \"Accetto questo limite\" oppure riduci a 18 MB per continuare."
|
|
)
|
|
)
|
|
self.large_size_panel.grid()
|
|
return
|
|
|
|
self.log_box.configure(state="normal")
|
|
self.log_box.delete("1.0", "end")
|
|
self.log_box.configure(state="disabled")
|
|
self.set_running(True)
|
|
|
|
worker = threading.Thread(
|
|
target=self.run_bundle,
|
|
args=(source, output, size_value, convert_uncompressed, convert_office),
|
|
daemon=True,
|
|
)
|
|
worker.start()
|
|
|
|
def run_bundle(self, source, output, size, convert_uncompressed, convert_office):
|
|
def thread_log(message):
|
|
self.after(0, self.append_log, message)
|
|
|
|
try:
|
|
bundle_folder(
|
|
source,
|
|
output,
|
|
size,
|
|
log=thread_log,
|
|
confirm_optimization=self.confirm_optimization,
|
|
convert_uncompressed_images=convert_uncompressed,
|
|
convert_office_to_pdf_enabled=convert_office,
|
|
)
|
|
except Exception as exc:
|
|
self.after(0, messagebox.showerror, "Errore", str(exc))
|
|
self.after(0, self.append_log, f"ERRORE: {exc}")
|
|
finally:
|
|
self.after(0, self.set_running, False)
|
|
|
|
|
|
def run_gui():
|
|
app = ZipBundlerApp()
|
|
app.mainloop()
|
|
|
|
|
|
def parse_args(argv):
|
|
parser = argparse.ArgumentParser(description="Crea pacchetti ZIP con dimensione massima reale.")
|
|
parser.add_argument("source", nargs="?", help="Cartella sorgente da comprimere.")
|
|
parser.add_argument("--output", "-o", help="Cartella dove salvare gli ZIP.")
|
|
parser.add_argument(
|
|
"--size-mb",
|
|
"-s",
|
|
type=float,
|
|
default=DEFAULT_MAX_ZIP_SIZE_MB,
|
|
help=f"Dimensione massima di ogni ZIP in MB. Default: {DEFAULT_MAX_ZIP_SIZE_MB}.",
|
|
)
|
|
parser.add_argument(
|
|
"--convert-uncompressed-images",
|
|
action="store_true",
|
|
help="Converte immagini BMP/TIFF in JPEG solo per la copia ridotta email.",
|
|
)
|
|
parser.add_argument(
|
|
"--convert-office-to-pdf",
|
|
action="store_true",
|
|
help="Converte file Office grandi in PDF solo quando superano il limite.",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv=None):
|
|
args = parse_args(argv if argv is not None else sys.argv[1:])
|
|
|
|
if not args.source:
|
|
run_gui()
|
|
return
|
|
|
|
source_folder = Path(args.source).resolve()
|
|
output_folder = Path(args.output).resolve() if args.output else source_folder.parent / f"{source_folder.name}_zip_20mb"
|
|
|
|
bundle_folder(
|
|
source_folder,
|
|
output_folder,
|
|
args.size_mb,
|
|
log=print,
|
|
convert_uncompressed_images=args.convert_uncompressed_images,
|
|
convert_office_to_pdf_enabled=args.convert_office_to_pdf,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|