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

הרצה:
    python scan_enhancer.py

תכונות:
  * גרירת PDF / תמונה לתוך החלון, או כפתור "פתח"
  * סליידרים: עובי כתב, בהירות, חדות, ניקוי רעש - תצוגה מקדימה חיה
  * הפרדת שורות שנכנסות זו לזו (לפני המרה לוורד/OCR) - פעילה כברירת מחדל, מתעדכנת חי
  * כפתור "זהה שורות וטורים + יישר": מזהה את הטיית הדף לפי השורות ומיישר אותו (Deskew)
  * זיהוי אוטומטי של שני טורים (לפי רווח רחב בהרבה ממרווח בין מילים) -
    אפשר להפריד שורות רק בטור ימין, רק בטור שמאל, או בשניהם
  * זום בגלגלת + 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, "angle": 0.0, "split_x": None, "cols": 1, "left": 0, "right": 0}


def _letter_height(ink):
    """גובה אות טיפוסי (חציון גובה רכיבים מחוברים, בלי כתמים קטנים)"""
    n, _, stats, _ = cv2.connectedComponentsWithStats(ink.astype(np.uint8), connectivity=8)
    if n < 3:
        return 10.0
    ar = stats[1:, cv2.CC_STAT_AREA]
    hh = stats[1:, cv2.CC_STAT_HEIGHT]
    ok = ar >= np.percentile(ar, 40)
    return float(max(4.0, np.median(hh[ok])))


def detect_columns(xr, letter_h, min_factor=2.0):
    """מזהה שני טורים: מחפש 'שדרה' ריקה אנכית באמצע הדף שרחבה גדול בהרבה ממרווח בין מילים
    (מינימום: min_factor * גובה אות). xr = קואורדינטה אופקית של פיקסלי הדיו (אחרי תיקון הטיה).
    מחזיר את מרכז השדרה, או None אם יש טור אחד."""
    if len(xr) < 200:
        return None
    x0 = float(xr.min())
    prof = np.bincount((xr - x0).astype(np.int64)).astype(np.float64)
    L = len(prof)
    if L < 50:
        return None
    ref = float(np.percentile(prof[prof > 0], 90))
    empty = prof <= max(0.5, 0.06 * ref)      # סובלני לכתמים בודדים ולכותרת שחוצה את הדף
    e = np.concatenate(([0], empty.astype(np.int8), [0]))
    d = np.diff(e)
    starts = np.nonzero(d == 1)[0]
    ends = np.nonzero(d == -1)[0]
    min_gap = max(letter_h * min_factor, L * 0.012)
    best = None
    for a, b in zip(starts, ends):
        if 0.25 * L <= (a + b) / 2.0 <= 0.75 * L and b - a >= min_gap:
            if best is None or b - a > best[1] - best[0]:
                best = (a, b)
    if best is None:
        return None
    split = x0 + (best[0] + best[1]) / 2.0
    left = int((xr < split).sum())
    if left < 0.12 * len(xr) or len(xr) - left < 0.12 * len(xr):
        return None
    return split


def deskew_image(img, adj=0.0):
    """מיישר את הדף לפי שורות הכתב (adj = כוונון ידני במעלות)"""
    if cv2 is None:
        return img
    arr = np.array(img)
    angle = _estimate_skew(_ink_mask(arr))
    SEP_INFO["angle"] = angle
    angle += adj
    if abs(angle) < 0.05:
        return img
    return Image.fromarray(_rotate_expand(arr, angle))


def analyze(img, col_factor=2.0):
    """ניתוח מהיר לכפתור הזיהוי: זווית הטיה, האם יש שני טורים, וכמה שורות בכל טור"""
    if cv2 is None:
        raise RuntimeError("חסרות הספריות: pip install numpy opencv-python-headless")
    if img.width > 1600:
        img = img.resize((1600, max(1, int(img.height * 1600.0 / img.width))), Image.LANCZOS)
    arr = np.array(img)
    angle = _estimate_skew(_ink_mask(arr))
    if abs(angle) > 0.05:
        arr = _rotate_expand(arr, angle)
    ink = _ink_mask(arr)
    ys, xs = np.nonzero(ink)
    res = {"angle": angle, "cols": 1, "left": 0, "right": 0, "lines": 0}
    if len(xs) < 50:
        return res

    def count(sel):
        if sel.sum() < 50:
            return 0
        cuts, _ = _find_cuts(ys[sel].astype(np.float64), 1.0)
        return len(cuts) + 1

    split = detect_columns(xs.astype(np.float64), _letter_height(ink), col_factor)
    if split is None:
        res["lines"] = count(np.ones(len(xs), bool))
    else:
        res["cols"] = 2
        res["left"] = count(xs < split)
        res["right"] = count(xs >= split)
    return res


def _paste_static(arr, ink):
    """מעתיק חלק מהדף כמו שהוא (ללא הזזה) על רקע לבן"""
    dil = cv2.dilate(ink.astype(np.uint8), np.ones((5, 5), np.uint8)) > 0
    out = np.full_like(arr, 255)
    out[dil] = arr[dil]
    return out


def _separate_part(arr, ink, gap, sens, angle):
    """מפריד שורות בחלק אחד של הדף (ink = הדיו של אותו חלק בלבד).
    מחזיר (שכבה על רקע לבן, תוספת גובה, מספר שורות)"""
    H, W = ink.shape
    ys, xs = np.nonzero(ink)
    if len(xs) < 50:
        return _paste_static(arr, ink), 0, 0
    r = np.deg2rad(angle)
    yr = -np.sin(r) * xs + np.cos(r) * ys
    cuts, pitch = _find_cuts(yr, sens)
    if not cuts:
        return _paste_static(arr, ink), 0, 1
    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)
    out = np.full((H + extra, W) + arr.shape[2:], 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 out, extra, nb


def separate_lines(img, gap, sens=1.0, cols_mode="both", col_factor=2.0):
    """מפריד שורות כתב שנכנסות זו לזו: כל שורה מוזזת למטה עד שנוצר רווח של לפחות gap פיקסלים.
    אם בדף שני טורים - כל טור מטופל בנפרד, ואפשר לבחור: both / left / right"""
    SEP_INFO.update(n=0, cols=1, split_x=None, left=0, right=0)
    if cv2 is None or gap == 0:
        return img
    arr = np.array(img)
    ink = _ink_mask(arr)
    ys, xs = np.nonzero(ink)
    if len(xs) < 50:
        return img
    H, W = ink.shape
    angle = _estimate_skew(ink)  # אם הדף כבר יושר זה ~0; אחרת מתחשב בהטיה בחישוב בלבד
    r = np.deg2rad(angle)
    xr = np.cos(r) * xs + np.sin(r) * ys
    split = detect_columns(xr, _letter_height(ink), col_factor)

    def mask_of(sel):
        m = np.zeros((H, W), bool)
        m[ys[sel], xs[sel]] = True
        return m

    static = None
    if split is None:
        todo = [("all", ink)]
    else:
        left, right = mask_of(xr < split), mask_of(xr >= split)
        SEP_INFO["cols"] = 2
        SEP_INFO["split_x"] = float((split - np.sin(r) * H / 2.0) / np.cos(r))
        if cols_mode == "left":
            todo, static = [("left", left)], right
        elif cols_mode == "right":
            todo, static = [("right", right)], left
        else:
            todo = [("left", left), ("right", right)]

    layers, extra, total = [], 0, 0
    for name, m in todo:
        layer, ex, nl = _separate_part(arr, m, gap, sens, angle)
        layers.append(layer)
        extra = max(extra, ex)
        total += nl
        if name in ("left", "right"):
            SEP_INFO[name] = nl
    if static is not None:
        layers.append(_paste_static(arr, static))
    SEP_INFO["n"] = total

    out = np.full((H + extra, W) + arr.shape[2:], 255, arr.dtype)
    for ly in layers:
        h = ly.shape[0]
        out[:h] = np.minimum(out[:h], ly)
    return Image.fromarray(out)


def process(img, p, scale=1.0):
    """p = מילון פרמטרים (ראה App.params)"""
    thick, bright, sharp = p["thick"], p["bright"], p["sharp"]
    despeckle, smooth, bw = p["despeckle"], p["smooth"], p["bw"]
    sepgap = p["sepgap"]

    # 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.2 יישור הטיית הדף (Deskew) - לפני הפרדת שורות
    if p["deskew"]:
        img = deskew_image(img, p["adj"])

    # 3.5 הפרדת שורות שנכנסות זו לזו (עם זיהוי שני טורים)
    if sepgap != 0:
        img = separate_lines(img, sepgap * scale if sepgap > 0 else -1,
                             p["sens"], p["cols"], p["colf"])

    # 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.v_adj = tk.DoubleVar(value=0.0)
        self.v_cols = tk.StringVar(value="both")
        self.v_colf = tk.DoubleVar(value=2.0)
        self.last_detect = ""
        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)

        df = ttk.LabelFrame(panel, text="יישור הדף (Deskew)", padding=6)
        df.pack(fill="x", pady=(8, 4))
        ttk.Button(df, text="זהה שורות וטורים + יישר", command=self.detect_deskew).pack(fill="x", pady=(0, 3))
        ttk.Checkbutton(df, text="יישור הדף פעיל (חל גם על השמירה)", variable=self.v_deskew,
                        command=self.refresh).pack(anchor="e")
        self.make_slider(df, "כוונון זווית ידני (מעלות)", self.v_adj, -5, 5, 0.05)

        cf = ttk.LabelFrame(panel, text="טורים (כשיש שני טורים בדף)", padding=6)
        cf.pack(fill="x", pady=4)
        ttk.Label(cf, text="הפרד שורות ב:", font=("Segoe UI", 10, "bold")).pack(anchor="e")
        for txt, val in (("שני הטורים", "both"), ("טור ימין בלבד", "right"), ("טור שמאל בלבד", "left")):
            ttk.Radiobutton(cf, text=txt, value=val, variable=self.v_cols,
                            command=self.refresh).pack(anchor="e")
        self.make_slider(cf, "מינימום רווח בין טורים (בגובה אות)", self.v_colf, 1.0, 6.0, 0.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(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.last_detect = ""
        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 {
            "thick": self.v_thick.get(), "bright": self.v_bright.get(),
            "sharp": self.v_sharp.get(), "despeckle": self.v_despeckle.get(),
            "smooth": self.v_smooth.get(), "bw": self.v_bw.get(),
            "sepgap": (-1 if self.v_auto.get() else self.v_gap.get()) if self.v_sep_on.get() else 0,
            "sens": self.v_sens.get(), "deskew": self.v_deskew.get(), "adj": self.v_adj.get(),
            "cols": self.v_cols.get(), "colf": self.v_colf.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])
            P = job[1]
            lines = []
            if cv2 is None and (P["sepgap"] != 0 or P["deskew"]):
                lines.append("חסרות הספריות: pip install numpy opencv-python-headless")
            else:
                if P["deskew"]:
                    lines.append(f"יושרה הטיה של {SEP_INFO['angle'] + P['adj']:.2f}°")
                if P["sepgap"] != 0:
                    n = SEP_INFO["n"]
                    if SEP_INFO["cols"] == 2:
                        mode = {"both": "שני הטורים", "right": "טור ימין בלבד",
                                "left": "טור שמאל בלבד"}[P["cols"]]
                        det = []
                        if P["cols"] in ("both", "right"):
                            det.append(f"ימין {SEP_INFO['right']}")
                        if P["cols"] in ("both", "left"):
                            det.append(f"שמאל {SEP_INFO['left']}")
                        lines.append(f"זוהו שני טורים - הופרדו: {mode}\nשורות: " + ", ".join(det))
                        sx = SEP_INFO["split_x"]
                        if sx is not None:
                            self.canvas.create_line(sx, 0, sx, out.height, fill="#00e5ff",
                                                    dash=(6, 4), width=2, tags="split")
                    else:
                        lines.append(f"זוהו {n} שורות" if n > 1
                                     else "לא זוהו שורות להפרדה - נסה להגדיל את הרגישות")
            if self.last_detect:
                lines.append("\n" + self.last_detect)
            if lines:
                self.status.config(text="\n".join(lines))
        if self.pending is not None:
            self.start_worker()
        else:
            self.root.config(cursor="")

    def detect_deskew(self):
        """זיהוי שורות וטורים בעמוד הנוכחי + הפעלת יישור הדף"""
        if self.src is None:
            return
        if cv2 is None:
            messagebox.showerror("חסרה ספרייה", "התקן:\npip install numpy opencv-python-headless")
            return
        self.status.config(text="מזהה שורות וטורים...")
        src, fac = self.src, self.v_colf.get()

        def run():
            try:
                res = analyze(src, fac)
            except Exception as e:
                res = e
            self.root.after(0, self.detect_done, res)

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

    def detect_done(self, res):
        if isinstance(res, Exception):
            self.status.config(text="שגיאה בזיהוי: " + str(res))
            return
        parts = [f"זווית הטיה שזוהתה: {res['angle']:.2f}°"]
        if res["cols"] == 2:
            parts.append(f"זוהו 2 טורים (ימין {res['right']} שורות, שמאל {res['left']} שורות).\n"
                         "בחר בחלונית 'טורים' על איזה טור להפריד.")
        else:
            parts.append(f"טור אחד, {res['lines']} שורות")
        self.last_detect = "\n".join(parts)
        self.v_deskew.set(True)
        self.refresh()

    def show_image(self, img, z):
        self.tkimg = ImageTk.PhotoImage(img)
        self.canvas.delete("img")
        self.canvas.delete("split")
        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.v_adj.set(0.0)
        self.v_cols.set("both")
        self.v_colf.set(2.0)
        self.last_detect = ""
        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()
