# -*- 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 _shift_bands(arr, ink, ys, xs, b, nb, gap, snap=True):
    """מזיז כל פס (שורה) למטה עד שנוצר רווח של gap פיקסלים בין פסים סמוכים.
    b = מספר הפס של כל פיקסל דיו. snap=True: רכיב מחובר (אות/מילה) שייך כולו לפס שבו רוב הפיקסלים שלו;
    snap=False: חיתוך מדויק לפי הגבול (לגבולות שצוירו ידנית)"""
    H, W = ink.shape
    if snap:
        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)
    else:
        band = b
    # פסים ריקים מוסרים, כדי שהרווח יישמר בין השורות שנותרו
    _, band = np.unique(band, return_inverse=True)
    band = band.ravel()
    nb = int(band.max()) + 1

    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


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)

    out, extra = _shift_bands(arr, ink, ys, xs, b, nb, gap, True)
    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 manual_separate(img, cuts, gap):
    """הפרדה לפי גבולות שצוירו ידנית. cuts = רשימת צורות חופשיות (נקודות מנורמלות 0..1 ביחס לתמונה).
    כל צורה היא 'שורה': מה שמעל הקצה העליון שלה, בתוכה, ומתחת לקצה התחתון שלה - נחשבים שורות נפרדות,
    וכל שורה מוזזת למטה עד שנוצר רווח של gap פיקסלים. קו פתוח (ציור לאורך הגבול) עובד כגבול בודד."""
    if cv2 is None or not cuts:
        return img
    arr = np.array(img)
    ink = _ink_mask(arr)
    H, W = ink.shape
    ys, xs = np.nonzero(ink)
    if len(xs) < 50:
        return img
    cols_idx = np.arange(W)
    curves = []
    for pts in cuts:
        if len(pts) < 2:
            continue
        poly = np.array([[x * W, y * H] for x, y in pts], np.float32).round().astype(np.int32)
        m = np.zeros((H, W), np.uint8)
        cv2.fillPoly(m, [poly], 255)
        cv2.polylines(m, [poly], True, 255, 3)
        has = m.any(0)
        if not has.any():
            continue
        top = np.where(has, m.argmax(0), 0).astype(np.float64)
        bot = np.where(has, H - 1 - m[::-1].argmax(0), 0).astype(np.float64)
        c = np.nonzero(has)[0]
        # מחוץ לטווח הצורה הקצה נמשך אופקית; בתוכה - אינטרפולציה
        curves.append((np.interp(cols_idx, c, top[c]), np.interp(cols_idx, c, bot[c])))
    if not curves:
        return img
    b = np.zeros(len(xs), np.int64)
    for top, bot in curves:
        b += (ys >= top[xs]).astype(np.int64)   # מתחת/על הקצה העליון של הצורה
        b += (ys > bot[xs]).astype(np.int64)    # מתחת לקצה התחתון של הצורה
    nb = int(b.max()) + 1
    if nb < 2:
        return img
    out, _ = _shift_bands(arr, ink, ys, xs, b, nb, max(2.0, gap), snap=False)
    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"])

    # מצב ציור: מציג את הדף אחרי היישור, כדי לצייר עליו גבולות
    if p.get("guide"):
        return img

    # 3.4 הפרדה לפי גבולות שצוירו ידנית
    if p.get("cuts") and p.get("mon", True):
        img = manual_separate(img, p["cuts"], p["mgap"] * scale)

    # 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.cuts = {}            # עמוד -> רשימת צורות חופשיות (נקודות מנורמלות)
        self.cuts_sig = None
        self.note = ""
        self.stroke = None
        self.disp_size = (1, 1)
        self.disp_is_guide = False
        self.v_draw = tk.BooleanVar(value=False)
        self.v_mon = tk.BooleanVar(value=True)
        self.v_mgap = tk.DoubleVar(value=40.0)
        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(1, weight=1)
        left.columnconfigure(0, weight=1)

        # סרגל כלים: ציור ידני של גבולות שורות
        tb = ttk.Frame(left, padding=(4, 3))
        tb.grid(row=0, column=0, columnspan=2, sticky="ew")
        ttk.Checkbutton(tb, text="ציור גבול שורה (חופשי)", variable=self.v_draw, style="Toolbutton",
                        command=self.toggle_draw).pack(side="right", padx=2)
        ttk.Button(tb, text="בטל אחרון", command=self.undo_cut).pack(side="right", padx=2)
        ttk.Button(tb, text="נקה עמוד", command=self.clear_cuts).pack(side="right", padx=2)
        ttk.Checkbutton(tb, text="החל גבולות", variable=self.v_mon,
                        command=self.refresh).pack(side="right", padx=6)
        self.cut_lbl = ttk.Label(tb, text="גבולות: 0")
        self.cut_lbl.pack(side="right", padx=6)
        tk.Scale(tb, from_=5, to=300, resolution=1, orient="horizontal", variable=self.v_mgap,
                 length=130, command=lambda _v: self.schedule()).pack(side="right")
        ttk.Label(tb, text="רווח (פיקסלים):").pack(side="right")

        self.canvas = tk.Canvas(left, bg="#3a3a3a", highlightthickness=0)
        self.canvas.grid(row=1, 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=1, column=1, sticky="ns")
        sx.grid(row=2, 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>", self.on_press)
        self.canvas.bind("<B1-Motion>", self.on_motion)
        self.canvas.bind("<ButtonRelease-1>", self.on_release)
        self.canvas.bind("<ButtonPress-3>", lambda e: self.canvas.scan_mark(e.x, e.y))
        self.canvas.bind("<B3-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, final=False):
        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(),
            "cuts": list(self.cuts.get(self.page, [])), "mgap": self.v_mgap.get(),
            "mon": self.v_mon.get(), "guide": (False if final else self.v_draw.get()),
        }

    # ------------------------------------------------------------ ציור גבולות ידני
    def toggle_draw(self):
        on = self.v_draw.get()
        self.canvas.config(cursor="pencil" if on else "")
        if on:
            self.show_orig.set(False)
        self.refresh()

    def update_cut_label(self):
        self.cut_lbl.config(text=f"גבולות: {len(self.cuts.get(self.page, []))}")

    def undo_cut(self):
        lst = self.cuts.get(self.page)
        if lst:
            lst.pop()
        self.refresh()

    def clear_cuts(self):
        self.cuts.pop(self.page, None)
        self.refresh()

    def on_press(self, e):
        if self.v_draw.get() and self.disp_is_guide and self.src is not None:
            self.stroke = [(self.canvas.canvasx(e.x), self.canvas.canvasy(e.y))]
        else:
            self.stroke = None
            self.canvas.scan_mark(e.x, e.y)

    def on_motion(self, e):
        if self.stroke is not None:
            x, y = self.canvas.canvasx(e.x), self.canvas.canvasy(e.y)
            lx, ly = self.stroke[-1]
            if abs(x - lx) + abs(y - ly) >= 2:
                self.stroke.append((x, y))
                self.canvas.create_line(lx, ly, x, y, fill="#ff3b30", width=2, tags="live")
        else:
            self.canvas.scan_dragto(e.x, e.y, gain=1)

    def on_release(self, e):
        st, self.stroke = self.stroke, None
        self.canvas.delete("live")
        if st is None or len(st) < 3:
            return
        w, h = self.disp_size
        pts = [(min(max(x / w, 0.0), 1.0), min(max(y / h, 0.0), 1.0)) for x, y in st]
        self.cuts.setdefault(self.page, []).append(pts)
        if self.v_sep_on.get():
            self.v_sep_on.set(False)   # ההפרדה האוטומטית כבויה כשעובדים עם גבולות ידניים
            self.note = "ההפרדה האוטומטית כובתה (אפשר להדליק מחדש בחלונית 'הפרדת שורות')"
        self.update_cut_label()
        self.draw_overlay()

    def draw_overlay(self):
        self.canvas.delete("cut")
        if not (self.v_draw.get() and self.disp_is_guide):
            return
        w, h = self.disp_size
        for pts in self.cuts.get(self.page, []):
            flat = []
            for x, y in pts + [pts[0]]:
                flat += [x * w, y * h]
            self.canvas.create_line(*flat, fill="#ff3b30", width=2, dash=(5, 3), tags="cut")

    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
        sig = (bool(self.v_deskew.get()), round(float(self.v_adj.get()), 2))
        if self.cuts_sig is not None and sig != self.cuts_sig and any(self.cuts.values()):
            self.cuts.clear()
            self.note = "הגבולות הידניים נמחקו כי היישור השתנה - צייר אותם מחדש"
        self.cuts_sig = sig
        self.update_cut_label()
        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.disp_is_guide = False
            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]
            self.disp_size = out.size
            self.disp_is_guide = bool(P["guide"])
            lines = []
            if P["guide"]:
                self.draw_overlay()
                lines.append("מצב ציור: לחץ וגרור כדי לצייר צורה חופשית סביב שורה "
                             "(או קו לאורך הגבול שמתחתיה).\nכפתור ימני בעכבר = הזזת הדף.")
            elif cv2 is None and (P["sepgap"] != 0 or P["deskew"] or P["cuts"]):
                lines.append("חסרות הספריות: pip install numpy opencv-python-headless")
            else:
                if P["cuts"] and P["mon"]:
                    lines.append(f"הופעלו {len(P['cuts'])} גבולות ידניים")
                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.note:
                lines.append("\n" + self.note)
                self.note = ""
            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.delete("cut")
        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.cuts.clear()
        self.v_mgap.set(40)
        self.v_mon.set(True)
        self.v_draw.set(False)
        self.canvas.config(cursor="")
        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(final=True), 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)
        cuts_all = {k: list(v) for k, v in self.cuts.items()}
        args = (p, self.params(final=True), int(self.dpi.get()), self.path, self.is_pdf, self.src, cuts_all)
        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, cuts_all):
        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, dict(params, cuts=cuts_all.get(i if is_pdf else 0, []), guide=False), 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()
