# -*- coding: utf-8 -*-
"""
מחדד דפים סרוקים - עיבוי כתב, בהירות וחדות
------------------------------------------
התקנה (פעם אחת):
    pip install pillow pymupdf tkinterdnd2 numpy opencv-python-headless

הרצה:
    python scan_enhancer.py

תכונות:
  * גרירת PDF / תמונה לתוך החלון, או כפתור "פתח"
  * סליידרים: עובי כתב, בהירות, חדות, ניקוי רעש - תצוגה מקדימה חיה
  * הפרדת שורות שנכנסות זו לזו (לפני המרה לוורד/OCR) - פעילה כברירת מחדל, מתעדכנת חי
  * זום בגלגלת + Ctrl, גרירה להזזה
  * שמירה כ-PDF (כל העמודים) או כתמונה (העמוד הנוכחי)
"""
import os
import sys
import io
import threading
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

from PIL import Image, ImageTk, ImageFilter, ImageEnhance, ImageOps

Image.MAX_IMAGE_PIXELS = None

try:
    import numpy as np
    import cv2
except Exception:
    np = None
    cv2 = None

try:
    import fitz  # PyMuPDF
except Exception:
    fitz = None

try:
    from tkinterdnd2 import TkinterDnD, DND_FILES
    HAS_DND = True
except Exception:
    HAS_DND = False

try:
    import ctypes
    ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
    pass

IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp")


# ---------------------------------------------------------------- עיבוד
def to_rgb(img):
    if img.mode == "RGBA":
        bg = Image.new("RGB", img.size, (255, 255, 255))
        bg.paste(img, mask=img.split()[3])
        return bg
    if img.mode not in ("RGB", "L"):
        return img.convert("RGB")
    return img


def remove_specks(img, min_area):
    """מוחק כתמים שחורים קטנים (רעש) - כל רכיב מחובר ששטחו קטן מ-min_area הופך ללבן"""
    if cv2 is None or min_area < 1:
        return img
    arr = np.array(img)
    gray = arr if arr.ndim == 2 else cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
    _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    n, labels, stats, _ = cv2.connectedComponentsWithStats(bw, connectivity=8)
    small = stats[:, cv2.CC_STAT_AREA] < min_area
    small[0] = False  # רקע
    arr[small[labels]] = 255
    return Image.fromarray(arr)


def _rotate_expand(arr, angle):
    h, w = arr.shape[:2]
    M = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0)
    c, sn = abs(M[0, 0]), abs(M[0, 1])
    nw, nh = int(h * sn + w * c) + 1, int(h * c + w * sn) + 1
    M[0, 2] += nw / 2.0 - w / 2.0
    M[1, 2] += nh / 2.0 - h / 2.0
    bv = (255, 255, 255) if arr.ndim == 3 else 255
    return cv2.warpAffine(arr, M, (nw, nh), flags=cv2.INTER_CUBIC, borderValue=bv)


def _ink_mask(arr):
    gray = arr if arr.ndim == 2 else cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
    _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    return bw > 0


def _estimate_skew(ink):
    """מוצא את זווית ההטיה של השורות (מעלות) לפי חדות פרופיל השורות"""
    ys, xs = np.nonzero(ink)
    if len(xs) < 50:
        return 0.0
    if len(xs) > 300000:
        idx = np.random.default_rng(0).choice(len(xs), 300000, replace=False)
        xs, ys = xs[idx], ys[idx]
    f = 1000.0 / max(ink.shape[1], 1)
    xs = xs * f
    ys = ys * f

    def score(a):
        r = np.deg2rad(a)
        y = -np.sin(r) * xs + np.cos(r) * ys
        y = (y - y.min()) / 2.0
        pr = np.bincount(y.astype(np.int64)).astype(np.float64)
        return float((pr ** 2).sum())

    best = max(np.arange(-25.0, 25.01, 0.5), key=score)
    best = max(np.arange(best - 0.5, best + 0.51, 0.1), key=score)
    return float(best)


def _find_cuts(yr, sens):
    """מחזיר קווי חיתוך (בקואורדינטות מסובבות) בין שורות סמוכות"""
    y0 = float(np.floor(yr.min()))
    prof = np.bincount((yr - y0).astype(np.int64)).astype(np.float64)
    L = len(prof)
    if L < 20:
        return [], 0
    p = prof - prof.mean()
    ac = np.correlate(p, p, mode="full")[L - 1:]
    neg = np.nonzero(ac < 0)[0]
    if len(neg) == 0:
        return [], 0
    start = int(neg[0])
    pitch = start + int(np.argmax(ac[start:]))
    pitch = max(pitch, 10)
    sigma = max(1.0, pitch * 0.12 * sens)
    rad = int(3 * sigma) + 1
    k = np.exp(-0.5 * (np.arange(-rad, rad + 1) / sigma) ** 2)
    sm = np.convolve(prof, k / k.sum(), mode="same")
    cand = [i for i in range(1, L - 1)
            if sm[i] > sm[i - 1] and sm[i] >= sm[i + 1] and sm[i] >= 0.25 * sm.max()]
    cand.sort(key=lambda i: -sm[i])
    peaks = []
    for i in cand:
        if all(abs(i - j) >= pitch * 0.5 for j in peaks):
            peaks.append(i)
    peaks.sort()
    if len(peaks) < 2:
        return [], 0
    cuts = []
    for a, b in zip(peaks[:-1], peaks[1:]):
        cuts.append(y0 + a + int(np.argmin(sm[a:b + 1])))
    return cuts, pitch


SEP_INFO = {"n": 0}


def separate_lines(img, gap, sens=1.0, deskew=False):
    """מפריד שורות כתב שנכנסות זו לזו: כל שורה מוזזת למטה עד שנוצר רווח של לפחות gap פיקסלים"""
    if cv2 is None or gap == 0:
        return img
    arr = np.array(img)
    ink = _ink_mask(arr)
    angle = _estimate_skew(ink)
    if deskew and abs(angle) > 0.1:
        arr = _rotate_expand(arr, angle)
        ink = _ink_mask(arr)
        angle = 0.0
    H, W = ink.shape
    ys, xs = np.nonzero(ink)
    if len(xs) < 50:
        return Image.fromarray(arr)
    r = np.deg2rad(angle)
    yr = -np.sin(r) * xs + np.cos(r) * ys
    cuts, pitch = _find_cuts(yr, sens)
    SEP_INFO["n"] = len(cuts) + 1
    if not cuts:
        return Image.fromarray(arr)
    if gap < 0:  # רווח אוטומטי לפי מרחק השורות
        gap = max(8.0, pitch * 0.5)
    nb = len(cuts) + 1
    b = np.searchsorted(cuts, yr)

    # רכיב מחובר (אות/מילה) שייך כולו לשורה שבה רוב הפיקסלים שלו; רק רכיבים "מודבקים" נחתכים
    ncomp, comp = cv2.connectedComponents(ink.astype(np.uint8), connectivity=8)
    c = comp[ys, xs]
    counts = np.bincount(c * nb + b, minlength=ncomp * nb).reshape(ncomp, nb)
    dom = counts.argmax(1)
    frac = counts.max(1) / np.maximum(counts.sum(1), 1)
    band = np.where(frac[c] >= 0.75, dom[c], b)

    labimg = np.zeros((H, W), np.int16)
    labimg[ys, xs] = band + 1

    big = 10 ** 9
    rad_x = max(1, int(gap))
    kk = np.ones((1, 2 * rad_x + 1), np.uint8)
    prof = []
    for k in range(nb):
        m = labimg == k + 1
        has = m.any(0)
        top = np.where(has, m.argmax(0), big).astype(np.float32)
        bot = np.where(has, H - 1 - m[::-1].argmax(0), -big).astype(np.float32)
        top = cv2.erode(top.reshape(1, -1), kk).ravel()
        bot = cv2.dilate(bot.reshape(1, -1), kk).ravel()
        prof.append((top, bot))

    dy = [0] * nb
    for k in range(1, nb):
        valid = (prof[k - 1][1] > -big / 2) & (prof[k][0] < big / 2)
        req = 0
        if valid.any():
            need = prof[k - 1][1] + dy[k - 1] + gap - prof[k][0]
            req = int(np.ceil(need[valid].max()))
        dy[k] = max(dy[k - 1], req)

    extra = max(dy)
    shape = (H + extra, W) + arr.shape[2:]
    out = np.full(shape, 255, arr.dtype)
    dil = cv2.dilate(labimg, np.ones((5, 5), np.uint8))
    lab = np.where(labimg > 0, labimg, dil)
    for k in range(nb):
        yy, xx = np.nonzero(lab == k + 1)
        out[yy + dy[k], xx] = np.minimum(out[yy + dy[k], xx], arr[yy, xx])
    return Image.fromarray(out)


def process(img, params, scale=1.0):
    """params = (עובי, בהירות, חדות, ניקוי כתמים, החלקה, סף שחור-לבן, רווח שורות, רגישות, יישור)"""
    thick, bright, sharp, despeckle, smooth, bw, sepgap, sepsens, deskew = params

    # 1. שחור-לבן (אופציונלי) - מנקה אפור ורעש חלש
    if bw > 0:
        g = img.convert("L").point(lambda v: 255 if v > bw else 0)
        img = g.convert("RGB") if img.mode == "RGB" else g

    # 2. ניקוי כתמים קטנים (לפני העיבוי, כדי שהרעש לא יתעבה)
    if despeckle > 0:
        img = remove_specks(img, despeckle * scale * scale)
        if cv2 is None:
            img = img.filter(ImageFilter.MedianFilter(3))

    # 3. החלקת קצוות
    if smooth > 0.05:
        n = max(1, int(round(smooth * scale)))
        k = 3 if n <= 1 else 5
        sm = img.filter(ImageFilter.MedianFilter(k))
        img = sm if smooth >= 1 or scale < 1 else Image.blend(img, sm, smooth)

    # 3.5 הפרדת שורות שנכנסות זו לזו
    if sepgap != 0:
        img = separate_lines(img, sepgap * scale if sepgap > 0 else -1, sepsens, deskew)

    # 4. עיבוי כתב
    t = thick * scale
    if t > 0.05:
        n = int(t)
        frac = t - n
        out = img
        for _ in range(n):
            out = out.filter(ImageFilter.MinFilter(3))
        if frac > 0.02:
            nxt = out.filter(ImageFilter.MinFilter(3))
            out = Image.blend(out, nxt, frac)
        img = out

    # 5. בהירות וחדות
    if abs(bright) > 0.5:
        img = ImageEnhance.Brightness(img).enhance(1 + bright / 100.0)
    if sharp > 0.05:
        img = ImageEnhance.Sharpness(img).enhance(1 + sharp)
    return img


# ---------------------------------------------------------------- אפליקציה
class App:
    def __init__(self, root):
        self.root = root
        root.title("מחדד דפים סרוקים")
        root.geometry("1400x860")

        self.path = None
        self.is_pdf = False
        self.doc = None
        self.page_count = 0
        self.page = 0
        self.src = None          # תמונת העמוד הנוכחי ברזולוציה מלאה
        self.base = None         # מוקטנת לפי זום
        self.base_zoom = None
        self.zoom = 1.0
        self.tkimg = None
        self.job = None
        self.saving = False

        self.v_thick = tk.DoubleVar(value=0.0)
        self.v_bright = tk.DoubleVar(value=0.0)
        self.v_sharp = tk.DoubleVar(value=0.0)
        self.v_despeckle = tk.DoubleVar(value=0.0)
        self.v_smooth = tk.DoubleVar(value=0.0)
        self.v_bw = tk.DoubleVar(value=0.0)
        self.v_gap = tk.DoubleVar(value=40.0)
        self.v_sep_on = tk.BooleanVar(value=True)
        self.v_auto = tk.BooleanVar(value=True)
        self.busy = False
        self.pending = None
        self.v_sens = tk.DoubleVar(value=1.0)
        self.v_deskew = tk.BooleanVar(value=False)
        self.show_orig = tk.BooleanVar(value=False)
        self.dpi = tk.StringVar(value="200")

        self.build_ui()

        if HAS_DND:
            root.drop_target_register(DND_FILES)
            root.dnd_bind("<<Drop>>", self.on_drop)

    # ------------------------------------------------------------ UI
    def build_ui(self):
        root = self.root
        root.columnconfigure(0, weight=1)
        root.rowconfigure(0, weight=1)

        # אזור תצוגה
        left = ttk.Frame(root)
        left.grid(row=0, column=0, sticky="nsew")
        left.rowconfigure(0, weight=1)
        left.columnconfigure(0, weight=1)

        self.canvas = tk.Canvas(left, bg="#3a3a3a", highlightthickness=0)
        self.canvas.grid(row=0, column=0, sticky="nsew")
        sy = ttk.Scrollbar(left, orient="vertical", command=self.canvas.yview)
        sx = ttk.Scrollbar(left, orient="horizontal", command=self.canvas.xview)
        sy.grid(row=0, column=1, sticky="ns")
        sx.grid(row=1, column=0, sticky="ew")
        self.canvas.configure(yscrollcommand=sy.set, xscrollcommand=sx.set)

        self.hint = self.canvas.create_text(
            400, 300, fill="#bbbbbb", font=("Segoe UI", 18),
            text="גרור לכאן PDF או תמונה\nאו לחץ על \"פתח\"" if HAS_DND
            else "לחץ על \"פתח\" לבחירת PDF או תמונה\n(להתקנת גרירה: pip install tkinterdnd2)",
            justify="center")

        self.canvas.bind("<MouseWheel>", self.on_wheel)
        self.canvas.bind("<Control-MouseWheel>", self.on_zoom_wheel)
        self.canvas.bind("<ButtonPress-1>", lambda e: self.canvas.scan_mark(e.x, e.y))
        self.canvas.bind("<B1-Motion>", lambda e: self.canvas.scan_dragto(e.x, e.y, gain=1))

        # פאנלים (ימין)
        panel = ttk.Frame(root, padding=8)
        panel.grid(row=0, column=1, sticky="ns")
        panel2 = ttk.Frame(root, padding=8)
        panel2.grid(row=0, column=2, sticky="ns")

        ttk.Button(panel, text="פתח...", command=self.open_dialog).pack(fill="x", pady=(0, 8))
        self.make_slider(panel, "עובי כתב", self.v_thick, 0, 6, 0.1)
        self.make_slider(panel, "בהירות", self.v_bright, -100, 100, 1)
        self.make_slider(panel, "חדות", self.v_sharp, 0, 5, 0.1)
        self.make_slider(panel, "ניקוי רעש (גודל כתם למחיקה)", self.v_despeckle, 0, 300, 1)
        self.make_slider(panel, "החלקת קצוות", self.v_smooth, 0, 1, 0.05)
        self.make_slider(panel, "שחור-לבן חד (סף, 0 = כבוי)", self.v_bw, 0, 254, 1)

        sepf = ttk.LabelFrame(panel2, text="הפרדת שורות", padding=6)
        sepf.pack(fill="x", pady=(0, 6))
        ttk.Checkbutton(sepf, text="הפרדת שורות פעילה (מתעדכן אוטומטית)", variable=self.v_sep_on,
                        command=self.refresh).pack(anchor="e", pady=(0, 2))
        ttk.Checkbutton(sepf, text="רווח אוטומטי (גרירת הסליידר עוברת לידני)", variable=self.v_auto,
                        command=self.on_auto_toggle).pack(anchor="e", pady=(0, 4))
        self.gap_scale = self.make_slider(sepf, "רווח ידני בין שורות (פיקסלים)", self.v_gap, 5, 300, 1,
                                    on_user=self.gap_moved)
        self.update_gap_state()
        self.make_slider(sepf, "רגישות זיהוי שורות", self.v_sens, 0.5, 2.0, 0.05)
        ttk.Checkbutton(sepf, text="ישר גם את הטיית השורות", variable=self.v_deskew,
                        command=self.refresh).pack(anchor="e")

        ttk.Checkbutton(panel2, text="הצג מקור (להשוואה)", variable=self.show_orig,
                        command=self.refresh).pack(anchor="e", pady=4)
        ttk.Button(panel2, text="איפוס סליידרים", command=self.reset).pack(fill="x", pady=(0, 6))

        zf = ttk.LabelFrame(panel2, text="תצוגה", padding=6)
        zf.pack(fill="x", pady=3)
        ttk.Button(zf, text="התאם לרוחב", command=self.fit_width).pack(fill="x", pady=1)
        ttk.Button(zf, text="100%", command=lambda: self.set_zoom(1.0)).pack(fill="x", pady=1)
        ttk.Label(zf, text="Ctrl + גלגלת = זום", foreground="#666").pack(anchor="e")
        self.zoom_lbl = ttk.Label(zf, text="")
        self.zoom_lbl.pack(anchor="e")

        pf = ttk.LabelFrame(panel2, text="עמודים", padding=6)
        pf.pack(fill="x", pady=3)
        nav = ttk.Frame(pf)
        nav.pack(fill="x")
        ttk.Button(nav, text="הבא", width=6, command=lambda: self.goto(self.page + 1)).pack(side="left")
        ttk.Button(nav, text="הקודם", width=6, command=lambda: self.goto(self.page - 1)).pack(side="right")
        self.page_lbl = ttk.Label(pf, text="-")
        self.page_lbl.pack(pady=2)

        sf = ttk.LabelFrame(panel2, text="שמירה", padding=6)
        sf.pack(fill="x", pady=3)
        row = ttk.Frame(sf)
        row.pack(fill="x", pady=2)
        ttk.Combobox(row, textvariable=self.dpi, values=("150", "200", "300", "400"),
                     width=5, state="readonly").pack(side="left")
        ttk.Label(row, text="DPI לעיבוד PDF").pack(side="right")
        ttk.Button(sf, text="שמור כ-PDF (כל העמודים)", command=self.save_pdf).pack(fill="x", pady=2)
        ttk.Button(sf, text="שמור עמוד נוכחי כתמונה", command=self.save_image).pack(fill="x", pady=2)
        self.progress = ttk.Progressbar(sf, mode="determinate")
        self.progress.pack(fill="x", pady=(6, 0))

        self.status = ttk.Label(panel2, text="", wraplength=240, justify="right")
        self.status.pack(fill="x", pady=6)

    def make_slider(self, parent, title, var, lo, hi, step, on_user=None):
        f = ttk.Frame(parent)
        f.pack(fill="x", pady=1)
        ttk.Label(f, text=title, font=("Segoe UI", 10, "bold")).pack(anchor="e")
        s = tk.Scale(f, from_=lo, to=hi, resolution=step, orient="horizontal",
                     variable=var, length=240, command=lambda _v: self.schedule())
        s.pack(fill="x")
        if on_user:
            s.bind("<ButtonPress-1>", lambda e: on_user(), add="+")
            s.bind("<B1-Motion>", lambda e: on_user(), add="+")
            s.bind("<Key>", lambda e: on_user(), add="+")
        return s

    # ------------------------------------------------------------ טעינה
    def on_drop(self, event):
        files = self.root.tk.splitlist(event.data)
        if files:
            self.load(files[0])

    def open_dialog(self):
        p = filedialog.askopenfilename(
            title="בחר PDF או תמונה",
            filetypes=[("PDF / תמונות", "*.pdf *.png *.jpg *.jpeg *.bmp *.tif *.tiff *.webp"),
                       ("כל הקבצים", "*.*")])
        if p:
            self.load(p)

    def load(self, path):
        ext = os.path.splitext(path)[1].lower()
        try:
            if self.doc is not None:
                self.doc.close()
                self.doc = None
            if ext == ".pdf":
                if fitz is None:
                    messagebox.showerror("חסרה ספרייה", "לעבודה עם PDF התקן:\npip install pymupdf")
                    return
                self.doc = fitz.open(path)
                self.is_pdf = True
                self.page_count = len(self.doc)
            elif ext in IMG_EXT:
                self.is_pdf = False
                self.page_count = 1
            else:
                messagebox.showwarning("סוג קובץ", "נתמכים: PDF ותמונות (PNG, JPG, TIF, BMP, WEBP)")
                return
            self.path = path
            self.page = 0
            self.load_page(fit=True)
        except Exception as e:
            messagebox.showerror("שגיאה בפתיחה", str(e))

    def render_pdf_page(self, doc, i, dpi):
        pix = doc[i].get_pixmap(matrix=fitz.Matrix(dpi / 72.0, dpi / 72.0), alpha=False)
        return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)

    def load_page(self, fit=False):
        self.status.config(text="טוען...")
        self.root.update_idletasks()
        if self.is_pdf:
            self.src = self.render_pdf_page(self.doc, self.page, int(self.dpi.get()))
        else:
            im = Image.open(self.path)
            im = ImageOps.exif_transpose(im)
            self.src = to_rgb(im)
        self.base = None
        self.base_zoom = None
        self.canvas.delete(self.hint)
        self.page_lbl.config(text=f"עמוד {self.page + 1} / {self.page_count}")
        self.status.config(text=os.path.basename(self.path) +
                           f"\n{self.src.width}×{self.src.height} px")
        if fit:
            self.fit_width(refresh=False)
        self.refresh()
        self.canvas.xview_moveto(0)
        self.canvas.yview_moveto(0)

    def goto(self, i):
        if self.src is None or not self.is_pdf:
            return
        if 0 <= i < self.page_count:
            self.page = i
            self.load_page()

    # ------------------------------------------------------------ תצוגה
    def schedule(self):
        if self.job:
            self.root.after_cancel(self.job)
        self.job = self.root.after(60, self.refresh)

    def params(self):
        return (self.v_thick.get(), self.v_bright.get(), self.v_sharp.get(),
                self.v_despeckle.get(), self.v_smooth.get(), self.v_bw.get(),
                (-1 if self.v_auto.get() else self.v_gap.get()) if self.v_sep_on.get() else 0,
                self.v_sens.get(), self.v_deskew.get())

    def update_gap_state(self):
        # הסליידר תמיד פעיל; כשהרווח אוטומטי הוא רק מוצג באפור
        self.gap_scale.config(fg="#999999" if self.v_auto.get() else "black")

    def gap_moved(self):
        """הזזה ידנית של הסליידר מפעילה הפרדה ומעבירה למצב רווח ידני"""
        if self.v_auto.get() or not self.v_sep_on.get():
            self.v_auto.set(False)
            self.v_sep_on.set(True)
            self.update_gap_state()

    def on_auto_toggle(self):
        self.update_gap_state()
        self.refresh()

    def refresh(self):
        """בונה את התצוגה. העיבוד רץ ברקע, והתוצאה האחרונה תמיד מוצגת (בלי הקפאת החלון)"""
        self.job = None
        if self.src is None:
            return
        z = self.zoom
        if self.base is None or self.base_zoom != z:
            w, h = self.src.size
            size = (max(1, int(w * z)), max(1, int(h * z)))
            self.base = self.src if z == 1.0 else self.src.resize(
                size, Image.LANCZOS if z < 1 else Image.BILINEAR)
            self.base_zoom = z
        if self.show_orig.get():
            self.pending = None
            self.show_image(self.base, z)
            return
        self.pending = (self.base, self.params(), min(z, 1.0), z, self.src)
        if not self.busy:
            self.start_worker()

    def start_worker(self):
        job = self.pending
        self.pending = None
        self.busy = True
        self.root.config(cursor="watch")

        def run():
            out, err = None, None
            try:
                out = process(job[0], job[1], job[2])
            except Exception as e:
                err = e
            self.root.after(0, self.worker_done, out, err, job)

        threading.Thread(target=run, daemon=True).start()

    def worker_done(self, out, err, job):
        self.busy = False
        if err is not None:
            self.status.config(text="שגיאת עיבוד: " + str(err))
        elif out is not None and job[4] is self.src and not self.show_orig.get():
            self.show_image(out, job[3])
            if job[1][6] != 0:
                n = SEP_INFO["n"]
                msg = f"זוהו {n} שורות" if n > 1 else "לא זוהו שורות להפרדה - נסה להגדיל את הרגישות"
                if cv2 is None:
                    msg = "חסרות הספריות: pip install numpy opencv-python-headless"
                self.status.config(text=msg)
        if self.pending is not None:
            self.start_worker()
        else:
            self.root.config(cursor="")

    def show_image(self, img, z):
        self.tkimg = ImageTk.PhotoImage(img)
        self.canvas.delete("img")
        self.canvas.create_image(0, 0, anchor="nw", image=self.tkimg, tags="img")
        self.canvas.config(scrollregion=(0, 0, img.width, img.height))
        self.zoom_lbl.config(text=f"זום: {int(z * 100)}%" +
                             ("" if z >= 1.0 else "  (קירוב; 100% מדויק)"))

    def set_zoom(self, z):
        z = max(0.05, min(1.0, z))
        self.zoom = z
        self.base = None
        self.refresh()

    def fit_width(self, refresh=True):
        if self.src is None:
            return
        cw = max(200, self.canvas.winfo_width() - 4)
        self.zoom = max(0.05, min(1.0, cw / self.src.width))
        self.base = None
        if refresh:
            self.refresh()

    def on_wheel(self, e):
        self.canvas.yview_scroll(-1 if e.delta > 0 else 1, "units")

    def on_zoom_wheel(self, e):
        self.set_zoom(self.zoom * (1.15 if e.delta > 0 else 1 / 1.15))

    def reset(self):
        for v in (self.v_thick, self.v_bright, self.v_sharp,
                  self.v_despeckle, self.v_smooth, self.v_bw):
            v.set(0)
        self.v_gap.set(40)
        self.v_sep_on.set(True)
        self.v_auto.set(True)
        self.update_gap_state()
        self.v_sens.set(1.0)
        self.v_deskew.set(False)
        self.refresh()

    # ------------------------------------------------------------ שמירה
    def default_name(self, ext):
        base = os.path.splitext(os.path.basename(self.path))[0]
        return base + "_enhanced" + ext

    def save_image(self):
        if self.src is None:
            return
        p = filedialog.asksaveasfilename(
            defaultextension=".png", initialfile=self.default_name(".png"),
            filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg")])
        if not p:
            return
        self.status.config(text="מעבד ושומר...")
        self.root.update_idletasks()
        out = process(self.src, self.params(), 1.0)
        if p.lower().endswith((".jpg", ".jpeg")):
            out.save(p, quality=95, dpi=(int(self.dpi.get()),) * 2)
        else:
            out.save(p, dpi=(int(self.dpi.get()),) * 2)
        self.status.config(text="נשמר:\n" + p)

    def save_pdf(self):
        if self.src is None or self.saving:
            return
        p = filedialog.asksaveasfilename(
            defaultextension=".pdf", initialfile=self.default_name(".pdf"),
            filetypes=[("PDF", "*.pdf")])
        if not p:
            return
        self.saving = True
        self.progress.config(maximum=self.page_count, value=0)
        args = (p, self.params(), int(self.dpi.get()), self.path, self.is_pdf, self.src)
        threading.Thread(target=self.pdf_worker, args=args, daemon=True).start()

    def pdf_worker(self, out_path, params, dpi, src_path, is_pdf, cur_img):
        try:
            if fitz is None:
                out = process(cur_img, params, 1.0)
                out.save(out_path, "PDF", resolution=dpi)
            else:
                outdoc = fitz.open()
                indoc = fitz.open(src_path) if is_pdf else None
                n = len(indoc) if is_pdf else 1
                for i in range(n):
                    img = self.render_pdf_page(indoc, i, dpi) if is_pdf else cur_img
                    img = process(img, params, 1.0)
                    buf = io.BytesIO()
                    img.save(buf, "JPEG", quality=92)
                    w_pt = img.width * 72.0 / dpi
                    h_pt = img.height * 72.0 / dpi
                    pg = outdoc.new_page(width=w_pt, height=h_pt)
                    pg.insert_image(pg.rect, stream=buf.getvalue())
                    self.root.after(0, self.set_progress, i + 1, n)
                outdoc.save(out_path, deflate=True, garbage=3)
                outdoc.close()
                if indoc:
                    indoc.close()
            self.root.after(0, self.save_done, "נשמר:\n" + out_path)
        except Exception as e:
            self.root.after(0, self.save_done, "שגיאה: " + str(e))

    def set_progress(self, i, n):
        self.progress.config(value=i)
        self.status.config(text=f"מעבד עמוד {i} / {n}")

    def save_done(self, msg):
        self.saving = False
        self.status.config(text=msg)


def main():
    root = TkinterDnD.Tk() if HAS_DND else tk.Tk()
    app = App(root)
    if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
        root.after(200, lambda: app.load(sys.argv[1]))
    root.mainloop()


if __name__ == "__main__":
    main()
