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("", 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("", self.scroll_wheel) canvas.bind("", 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()