Release ZipBundler 1.0.0 with guided recompression and complete Windows build
This commit is contained in:
433
zipbundler.py
433
zipbundler.py
@@ -5,10 +5,12 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import warnings
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox
|
||||
from tkinter import Canvas, filedialog, messagebox
|
||||
|
||||
import customtkinter as ctk
|
||||
import fitz
|
||||
@@ -24,6 +26,8 @@ PDF_OPTIMIZE_PROFILES = (
|
||||
("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 = (
|
||||
@@ -57,6 +61,14 @@ class OptimizedPdf:
|
||||
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):
|
||||
@@ -68,6 +80,8 @@ def format_mb(size):
|
||||
|
||||
|
||||
def optimization_label(report):
|
||||
if report.profile == "originale":
|
||||
return "originale: nessuna riduzione utile"
|
||||
if report.kind == "office":
|
||||
return "conversione PDF"
|
||||
if report.kind == "image":
|
||||
@@ -76,7 +90,7 @@ def optimization_label(report):
|
||||
"qualita_email": "qualita email",
|
||||
"massima_compressione": "massima compressione",
|
||||
}
|
||||
return labels.get(report.profile, report.profile)
|
||||
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:
|
||||
@@ -191,7 +205,7 @@ def optimized_pdf_result(source_file, optimized_path, profile, dpi, quality):
|
||||
)
|
||||
|
||||
|
||||
def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None):
|
||||
def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None, dpi=None, quality=None):
|
||||
ghostscript = find_ghostscript()
|
||||
if not ghostscript:
|
||||
if log:
|
||||
@@ -204,7 +218,7 @@ def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None)
|
||||
|
||||
if log:
|
||||
log(
|
||||
f"Provo Ghostscript /{profile}: "
|
||||
f"Provo Ghostscript {str(dpi) + ' DPI' if dpi else '/' + profile}: "
|
||||
f"{source_file.arcname} ({format_mb(source_file.stat().st_size)})"
|
||||
)
|
||||
|
||||
@@ -220,14 +234,36 @@ def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None)
|
||||
"-dCompressFonts=true",
|
||||
"-dSubsetFonts=true",
|
||||
f"-sOutputFile={optimized_path}",
|
||||
str(source_file.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():
|
||||
@@ -240,9 +276,9 @@ def optimize_pdf_with_ghostscript(source_file, output_folder, profile, log=None)
|
||||
return optimized_pdf_result(
|
||||
source_file,
|
||||
optimized_path,
|
||||
profile=f"ghostscript_{profile}",
|
||||
dpi=profile,
|
||||
quality=None,
|
||||
profile=f"dpi_{dpi}" if dpi else f"ghostscript_{profile}",
|
||||
dpi=dpi if dpi else profile,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
|
||||
@@ -302,7 +338,7 @@ def optimize_pdf(source_file, output_folder, profile, dpi, quality, log=None):
|
||||
|
||||
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 = 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(
|
||||
@@ -425,6 +461,21 @@ def image_target_format(source_path, convert_uncompressed=False):
|
||||
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)
|
||||
@@ -445,7 +496,9 @@ def optimize_image(source_file, output_folder, profile, max_side, quality, log=N
|
||||
f"({format_mb(source_file.stat().st_size)})"
|
||||
)
|
||||
|
||||
with Image.open(source_file.path) as image:
|
||||
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:
|
||||
@@ -465,42 +518,14 @@ def optimize_image(source_file, output_folder, profile, max_side, quality, log=N
|
||||
|
||||
|
||||
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,
|
||||
attempts = [
|
||||
lambda p=p, side=side, q=q: optimize_image(
|
||||
source_file, output_folder, p, side, q, 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
|
||||
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):
|
||||
@@ -604,7 +629,7 @@ def convert_office_to_pdf(source_file, output_folder, log=None):
|
||||
return optimized_office_result(source_file, pdf_path)
|
||||
|
||||
|
||||
def try_convert_office_for_limit(source_file, output_folder, max_zip_size, log=None):
|
||||
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
|
||||
@@ -613,19 +638,55 @@ def try_convert_office_for_limit(source_file, output_folder, max_zip_size, log=N
|
||||
zip_size = compressed_zip_size([converted.optimized], temp_folder)
|
||||
|
||||
if zip_size <= max_zip_size:
|
||||
return converted
|
||||
return replace(converted, zip_size=zip_size, max_zip_size=max_zip_size)
|
||||
|
||||
if log:
|
||||
log(f"Il PDF convertito e ancora troppo grande: {format_mb(zip_size)} nello ZIP.")
|
||||
report, _ = choose_pdf_for_limit(
|
||||
converted.optimized, output_folder, max_zip_size, log=log,
|
||||
confirm_optimization=confirm_optimization,
|
||||
)
|
||||
return report
|
||||
|
||||
converted.optimized.path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
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):
|
||||
original_size = source_file.stat().st_size
|
||||
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:
|
||||
@@ -635,38 +696,89 @@ def try_optimize_pdf_for_limit(source_file, output_folder, max_zip_size, log=Non
|
||||
)
|
||||
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
|
||||
attempts.append(lambda dpi=dpi: optimize_pdf_with_ghostscript(
|
||||
source_file, output_folder, dpi, log=log,
|
||||
))
|
||||
else:
|
||||
optimized = optimize_pdf(source_file, output_folder, profile_name, dpi, quality, log=log)
|
||||
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)
|
||||
|
||||
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."
|
||||
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)
|
||||
|
||||
return None
|
||||
|
||||
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(
|
||||
@@ -706,20 +818,22 @@ def split_into_groups(
|
||||
|
||||
replacement = None
|
||||
if file_path.path.suffix.lower() == ".pdf":
|
||||
replacement = try_optimize_pdf_for_limit(
|
||||
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 = try_optimize_image_for_limit(
|
||||
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(
|
||||
@@ -727,17 +841,20 @@ def split_into_groups(
|
||||
output_folder,
|
||||
max_zip_size,
|
||||
log=log,
|
||||
confirm_optimization=confirm_optimization,
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -762,16 +879,11 @@ def split_into_groups(
|
||||
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])
|
||||
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)
|
||||
@@ -899,21 +1011,25 @@ def pdf_page_count(pdf_path):
|
||||
doc.close()
|
||||
|
||||
|
||||
def render_pdf_preview(pdf_path, page_index=0, max_size=(360, 480)):
|
||||
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)
|
||||
pixmap = page.get_pixmap(matrix=fitz.Matrix(1.2, 1.2), alpha=False)
|
||||
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)
|
||||
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:
|
||||
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)
|
||||
@@ -922,9 +1038,9 @@ def render_image_preview(image_path, max_size=(360, 480)):
|
||||
return image.copy()
|
||||
|
||||
|
||||
def render_preview(report, path, page_index=0):
|
||||
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)
|
||||
return render_pdf_preview(path, page_index, max_size=max_size, zoom=zoom)
|
||||
return render_image_preview(path)
|
||||
|
||||
|
||||
@@ -932,9 +1048,11 @@ class OptimizationDialog(ctk.CTkToplevel):
|
||||
def __init__(self, parent, report):
|
||||
super().__init__(parent)
|
||||
self.report = report
|
||||
self.result = False
|
||||
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),
|
||||
@@ -949,14 +1067,18 @@ class OptimizationDialog(ctk.CTkToplevel):
|
||||
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(
|
||||
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")
|
||||
@@ -973,6 +1095,13 @@ class OptimizationDialog(ctk.CTkToplevel):
|
||||
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
|
||||
@@ -992,14 +1121,20 @@ class OptimizationDialog(ctk.CTkToplevel):
|
||||
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(
|
||||
ctk.CTkButton(buttons, text="Annulla elaborazione", width=160, command=self.cancel).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.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.reject)
|
||||
self.protocol("WM_DELETE_WINDOW", self.cancel)
|
||||
|
||||
def _build_preview_column(self, column, label, size_text):
|
||||
frame = ctk.CTkFrame(self, corner_radius=8)
|
||||
@@ -1010,23 +1145,48 @@ class OptimizationDialog(ctk.CTkToplevel):
|
||||
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")
|
||||
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)
|
||||
|
||||
if column == 0:
|
||||
self.original_image_label = image_label
|
||||
else:
|
||||
self.optimized_image_label = image_label
|
||||
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)
|
||||
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)
|
||||
render_preview(self.report, self.report.optimized.path, self.page_index, self.zoom, max_size)
|
||||
)
|
||||
self.original_image_label.configure(image=self.original_image)
|
||||
self.optimized_image_label.configure(image=self.optimized_image)
|
||||
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:
|
||||
@@ -1054,6 +1214,10 @@ class OptimizationDialog(ctk.CTkToplevel):
|
||||
self.result = False
|
||||
self.destroy()
|
||||
|
||||
def cancel(self):
|
||||
self.result = None
|
||||
self.destroy()
|
||||
|
||||
|
||||
class LargeEmailSizeDialog(ctk.CTkToplevel):
|
||||
def __init__(self, parent, size_value):
|
||||
@@ -1349,17 +1513,26 @@ class ZipBundlerApp(ctk.CTk):
|
||||
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():
|
||||
dialog = OptimizationDialog(self, report)
|
||||
self.wait_window(dialog)
|
||||
answer["value"] = dialog.result
|
||||
event.set()
|
||||
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):
|
||||
@@ -1431,6 +1604,8 @@ class ZipBundlerApp(ctk.CTk):
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user