""" Deskew Tool v2 — interaktivní oprava sklonu dokumentu. Použití: Klikej na body podél JAKÉKOLI rovné hrany (min. 2 body). Skript sám rozpozná, jestli je hrana horizontální nebo vertikální. Výsledný úhel lze doladit sliderem. Náhled před uložením. """ import math import sys from pathlib import Path import tkinter as tk from tkinter import messagebox import numpy as np from PIL import Image, ImageTk IMAGE_PATH = Path(__file__).parent / "2518e068-c7fb-44fc-b130-c75f104ea50e.jpeg" OUTPUT_PDF = Path(__file__).parent / "opraveny_dokument.pdf" MAX_DISPLAY = (1300, 820) def fit_scale(img_w, img_h, max_w, max_h): scale = min(max_w / img_w, max_h / img_h, 1.0) return scale, int(img_w * scale), int(img_h * scale) def angle_of_points(pts): """Lineární regrese → úhel přímky procházející body (ve stupních, -90..+90).""" xs = np.array([p[0] for p in pts], dtype=float) ys = np.array([p[1] for p in pts], dtype=float) if len(pts) < 2: return 0.0 # fit line if np.ptp(xs) < 1e-6: # téměř vertikální return 90.0 coeffs = np.polyfit(xs, ys, 1) slope = coeffs[0] # dy/dx return math.degrees(math.atan(slope)) # -90..+90 def rotation_from_edge(pts): """ Z bodů na hraně určí potřebnou rotaci. Automaticky rozpozná, jestli je hrana blíž horizontální nebo vertikální. """ a = angle_of_points(pts) # úhel vůči horizontální ose (y↓ souřadnice) # Je-li hrana blíže horizontální (|a| < 45°): přímá korekce if abs(a) <= 45: return -a # Je-li blíže vertikální: hrana by měla být ±90° if a > 0: return -(a - 90) else: return -(a + 90) def rotate_image(img, angle): """Rotuje PIL Image kolem středu, bílé pozadí, zachová rozměry obsahu.""" return img.rotate(angle, expand=True, fillcolor="white", resample=Image.BICUBIC) class DeskewApp: DOT_COLOR = "#e74c3c" LINE_COLOR = "#e74c3c" SLIDER_RES = 0.05 # krok slideru ve stupních def __init__(self, root, image_path): self.root = root self.root.title("Deskew Tool v2") self.orig_img = Image.open(image_path) iw, ih = self.orig_img.size self.scale, dw, dh = fit_scale(iw, ih, *MAX_DISPLAY) self.disp_img = self.orig_img.resize((dw, dh), Image.LANCZOS) self.tk_img = ImageTk.PhotoImage(self.disp_img) self.points = [] # (x_orig, y_orig) self.rot_var = tk.DoubleVar(value=0.0) self._build_ui(dw, dh) # ------------------------------------------------------------------ UI -- def _build_ui(self, dw, dh): # --- horní panel --- top = tk.Frame(self.root) top.pack(fill=tk.X, padx=8, pady=(6, 2)) self.lbl = tk.Label( top, text="Klikni na body podél rovné hrany (min. 2 body) — Skript sám určí orientaci hrany", font=("Segoe UI", 10), fg="#333", anchor="w", ) self.lbl.pack(side=tk.LEFT, fill=tk.X, expand=True) tk.Button(top, text="Reset", command=self.reset, width=8).pack(side=tk.RIGHT, padx=2) tk.Button(top, text="Spočítej úhel", command=self.compute_angle, width=14, bg="#2980b9", fg="white").pack(side=tk.RIGHT, padx=2) # --- canvas --- self.canvas = tk.Canvas(self.root, width=dw, height=dh, cursor="crosshair") self.canvas.pack(padx=8, pady=2) self.canvas.create_image(0, 0, anchor=tk.NW, image=self.tk_img) self.canvas.bind("", self.on_click) # --- slider panel --- bot = tk.Frame(self.root) bot.pack(fill=tk.X, padx=8, pady=(2, 6)) tk.Label(bot, text="Rotace (°):", font=("Segoe UI", 10)).pack(side=tk.LEFT) self.lbl_angle = tk.Label(bot, text="0.00°", width=8, font=("Segoe UI", 10, "bold"), fg="#c0392b") self.lbl_angle.pack(side=tk.LEFT, padx=4) self.slider = tk.Scale( bot, from_=-20, to=20, resolution=self.SLIDER_RES, orient=tk.HORIZONTAL, variable=self.rot_var, length=400, command=self._on_slider, showvalue=False, ) self.slider.pack(side=tk.LEFT, padx=6) tk.Button(bot, text="Náhled", command=self.preview, width=10, bg="#8e44ad", fg="white").pack(side=tk.LEFT, padx=6) self.btn_save = tk.Button( bot, text="Ulož PDF", command=self.save_pdf, width=12, bg="#27ae60", fg="white", font=("Segoe UI", 10, "bold"), ) self.btn_save.pack(side=tk.LEFT, padx=4) self.root.bind("", lambda e: self.save_pdf()) # ---------------------------------------------------------- interactions -- def on_click(self, event): x_orig = event.x / self.scale y_orig = event.y / self.scale self.points.append((x_orig, y_orig)) r = 6 self.canvas.create_oval( event.x - r, event.y - r, event.x + r, event.y + r, outline=self.DOT_COLOR, fill=self.DOT_COLOR, ) # průběžná regresní přímka if len(self.points) >= 2: self._redraw_line() n = len(self.points) suffix = 'y' if 1 < n < 5 else 'ů' if n >= 5 else '' self.lbl.config(text=f"{n} bod{suffix} — klikej dál nebo stiskni 'Spočítej úhel'") def _redraw_line(self): """Překreslí regresní přímku přes celou canvas.""" self.canvas.delete("regline") if len(self.points) < 2: return xs = [p[0] * self.scale for p in self.points] ys = [p[1] * self.scale for p in self.points] x0, x1 = 0, self.canvas.winfo_width() if np.ptp(xs) < 1: self.canvas.create_line(xs[0], 0, xs[0], self.canvas.winfo_height(), fill=self.LINE_COLOR, width=2, dash=(8,4), tags="regline") return coeffs = np.polyfit(xs, ys, 1) y0 = coeffs[0] * x0 + coeffs[1] y1 = coeffs[0] * x1 + coeffs[1] self.canvas.create_line(x0, y0, x1, y1, fill=self.LINE_COLOR, width=2, dash=(8, 4), tags="regline") def compute_angle(self): if len(self.points) < 2: messagebox.showwarning("Málo bodů", "Klikni alespoň na 2 body podél hrany.") return rot = rotation_from_edge(self.points) self.rot_var.set(round(rot, 2)) self.lbl_angle.config(text=f"{rot:+.2f}°") self.lbl.config(text=f"Vypočítaná rotace: {rot:+.2f}° — dolaď sliderem nebo klikej na Náhled / Ulož PDF") def _on_slider(self, val): v = float(val) self.lbl_angle.config(text=f"{v:+.2f}°") def reset(self): self.points.clear() self.canvas.delete("all") self.canvas.create_image(0, 0, anchor=tk.NW, image=self.tk_img) self.rot_var.set(0.0) self.lbl_angle.config(text="0.00°") self.lbl.config(text="Klikni na body podél rovné hrany (min. 2 body) — Skript sám určí orientaci hrany") def _corrected_image(self): return rotate_image(self.orig_img, self.rot_var.get()) def preview(self): rot = self.rot_var.get() corrected = self._corrected_image() pw, ph = corrected.size scale2, dw2, dh2 = fit_scale(pw, ph, *MAX_DISPLAY) thumb = corrected.resize((dw2, dh2), Image.LANCZOS) win = tk.Toplevel(self.root) win.title(f"Náhled — rotace {rot:+.2f}°") tk_thumb = ImageTk.PhotoImage(thumb) lbl = tk.Label(win, image=tk_thumb) lbl.image = tk_thumb # drž referenci lbl.pack() def save_pdf(self): corrected = self._corrected_image() if corrected.mode != "RGB": corrected = corrected.convert("RGB") corrected.save(OUTPUT_PDF, "PDF", resolution=200) rot = self.rot_var.get() messagebox.showinfo("Uloženo", f"Rotace: {rot:+.2f}°\nPDF:\n{OUTPUT_PDF}") self.root.destroy() def main(): if not IMAGE_PATH.exists(): sys.exit(f"Obrázek nenalezen: {IMAGE_PATH}") root = tk.Tk() DeskewApp(root, IMAGE_PATH) root.mainloop() if __name__ == "__main__": main()