This commit is contained in:
2026-06-02 09:40:05 +02:00
parent d5f2dc3925
commit e79458d670
16 changed files with 2597 additions and 49 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,291 @@
"""
Kombinovaný viewer: náhled originálu + náhled duplicit + listbox duplicit.
Nahrazuje preview_viewer.py pro hlavní dokument.
Spouští se jako subprocess z extract_patient_info_novy_test.py.
Argumenty: duplicity_viewer.py <json_soubor> [--write-geometry=<cesta>]
JSON vstup: {
"original": "cesta/k/originalu.pdf", # povinné
"duplicity": ["cesta1.pdf", ...], # volitelné, může být prázdné
"labels": ["název1.pdf", ...] # zobrazované názvy v listboxu
}
Výstup geometrie (spodní hrana) do --write-geometry souboru pro rename_dialog.
"""
import json
import sys
from pathlib import Path
import tkinter as tk
if sys.platform == "win32":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
def _open_doc(path_str: str):
"""Otevře PDF nebo obrázek, vrátí (fitz.Document nebo None, PIL.Image nebo None)."""
import fitz
from PIL import Image
p = Path(path_str)
if not p.exists():
return None, None
suffix = p.suffix.lower()
if suffix in (".jpg", ".jpeg", ".png"):
return None, Image.open(p)
return fitz.open(str(p)), None
def _render_page(doc, pil_img, page_n: int, max_w: int, max_h: int):
"""Vykreslí stránku, vrátí PIL.Image."""
import fitz
from PIL import Image
if doc is not None:
page = doc[page_n]
zoom = min(max_w / page.rect.width, max_h / page.rect.height)
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
elif pil_img is not None:
img = pil_img.copy()
img.thumbnail((max_w, max_h))
return img
return None
class PdfPane:
"""Jeden panel s náhledem PDF + navigací stránek."""
def __init__(self, parent, max_w: int, max_h: int, bg: str = "#222"):
self.max_w = max_w
self.max_h = max_h
self.doc = None
self.pil_img = None
self.page_n = 0
self.page_count = 1
self.photo_ref = None
self.frame = tk.Frame(parent, bg=bg, width=max_w)
self.frame.pack_propagate(False)
self.lbl_title = tk.Label(self.frame, text="", bg=bg, fg="#aaa",
font=("Segoe UI", 9), wraplength=max_w - 10)
self.lbl_title.pack(pady=(4, 2))
self.lbl_img = tk.Label(self.frame, bg=bg)
self.lbl_img.pack(fill="both", expand=True)
frame_nav = tk.Frame(self.frame, bg=bg)
frame_nav.pack(pady=4)
self.lbl_page = tk.Label(frame_nav, text="", bg=bg, fg="#ccc",
font=("Segoe UI", 9))
self.lbl_page.pack(side="left", padx=6)
self.btn_prev = tk.Button(frame_nav, text="", bg="#444", fg="#fff",
relief="flat", padx=6,
command=self._prev)
self.btn_prev.pack(side="left", padx=2)
self.btn_next = tk.Button(frame_nav, text="", bg="#444", fg="#fff",
relief="flat", padx=6,
command=self._next)
self.btn_next.pack(side="left", padx=2)
def load(self, path_str: str, title: str = ""):
from PIL import ImageTk
if self.doc:
try:
self.doc.close()
except Exception:
pass
self.doc, self.pil_img = _open_doc(path_str)
self.page_count = len(self.doc) if self.doc else 1
self.lbl_title.config(text=title or Path(path_str).name, fg="#ddd")
self._show(0)
def clear(self, msg: str = ""):
if self.doc:
try:
self.doc.close()
except Exception:
pass
self.doc = None
self.pil_img = None
self.lbl_img.config(image="")
self.lbl_title.config(text=msg, fg="#666")
self.lbl_page.config(text="")
self.btn_prev.config(state="disabled")
self.btn_next.config(state="disabled")
self.photo_ref = None
def _show(self, n: int):
from PIL import ImageTk
self.page_n = n
img = _render_page(self.doc, self.pil_img, n, self.max_w - 10, self.max_h - 60)
if img:
self.photo_ref = ImageTk.PhotoImage(img)
self.lbl_img.config(image=self.photo_ref)
self.lbl_page.config(text=f"{n + 1} / {self.page_count}" if self.page_count > 1 else "")
self.btn_prev.config(state="normal" if n > 0 else "disabled")
self.btn_next.config(state="normal" if n < self.page_count - 1 else "disabled")
def _prev(self):
if self.page_n > 0:
self._show(self.page_n - 1)
def _next(self):
if self.page_n < self.page_count - 1:
self._show(self.page_n + 1)
def close(self):
if self.doc:
try:
self.doc.close()
except Exception:
pass
def main():
if len(sys.argv) < 2:
sys.exit(1)
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
original_path = data.get("original") or ""
duplicity_paths = data.get("duplicity") or []
labels = data.get("labels") or [Path(p).name for p in duplicity_paths]
write_geom = None
for arg in sys.argv:
if arg.startswith("--write-geometry="):
write_geom = Path(arg.split("=", 1)[1])
try:
from PIL import Image, ImageTk
import fitz
except ImportError as e:
print(f"[duplicity_viewer] Chybí knihovna: {e}", file=sys.stderr)
sys.exit(2)
# ── Layout z JSON ─────────────────────────────────────────────────────────
sys.path.insert(0, str(Path(__file__).parent))
try:
from window_layout import get_layout
layout = get_layout()
lw = layout.get("duplicity_viewer") or {}
except Exception:
lw = {}
WIN_X = lw.get("x", 0)
WIN_Y = lw.get("y", 0)
WIN_W = lw.get("w", 2200)
WIN_H = lw.get("h", 1080)
LISTBOX_W = 380
PANE_W = (WIN_W - LISTBOX_W - 20) // 2
PANE_H = WIN_H - 40
# ── Okno ──────────────────────────────────────────────────────────────────
root = tk.Tk()
root.tk.call("encoding", "system", "utf-8")
root.title("Náhled dokumentů")
root.configure(bg="#1a1a1a")
root.geometry(f"{WIN_W}x{WIN_H}+{WIN_X}+{WIN_Y}")
# ── Tři sloupce ───────────────────────────────────────────────────────────
# Vlevo: originál
pane_orig = PdfPane(root, max_w=PANE_W, max_h=PANE_H, bg="#1e1e2e")
pane_orig.frame.pack(side="left", fill="both", expand=False, padx=(6, 3), pady=6)
# Uprostřed: duplicita
pane_dup = PdfPane(root, max_w=PANE_W, max_h=PANE_H, bg="#2e1e1e")
pane_dup.frame.pack(side="left", fill="both", expand=False, padx=(3, 3), pady=6)
# Vpravo: listbox
frame_right = tk.Frame(root, bg="#1a1a1a", width=LISTBOX_W)
frame_right.pack(side="left", fill="y", padx=(3, 6), pady=6)
frame_right.pack_propagate(False)
tk.Label(frame_right, text="Existující dokumenty:", anchor="w",
bg="#1a1a1a", fg="#cc4444", font=("Segoe UI", 9, "bold")).pack(
fill="x", padx=4, pady=(8, 2))
sb = tk.Scrollbar(frame_right, orient="vertical")
lb = tk.Listbox(
frame_right,
yscrollcommand=sb.set,
font=("Segoe UI", 8),
selectmode="single",
activestyle="dotbox",
bg="#2a1a1a",
fg="#ddd",
selectbackground="#cc4444",
selectforeground="#fff",
cursor="hand2",
)
sb.config(command=lb.yview)
sb.pack(side="right", fill="y")
lb.pack(side="left", fill="both", expand=True, padx=(4, 0))
if not duplicity_paths:
lb.insert(tk.END, "(žádné duplicity)")
lb.config(state="disabled")
else:
for label in labels:
lb.insert(tk.END, Path(label).name if Path(label).exists() else label)
# ── Načti originál ────────────────────────────────────────────────────────
if original_path and Path(original_path).exists():
pane_orig.load(original_path, title=Path(original_path).name)
else:
pane_orig.clear(msg="(originál nedostupný)")
pane_dup.clear(msg="← vyberte duplicitu vlevo" if duplicity_paths else "(žádné duplicity)")
# ── Výběr duplicity ───────────────────────────────────────────────────────
def on_select(event):
sel = lb.curselection()
if not sel:
return
idx = sel[0]
p = duplicity_paths[idx]
pane_dup.load(p, title=labels[idx] if idx < len(labels) else Path(p).name)
lb.bind("<<ListboxSelect>>", on_select)
# Automaticky vyber první duplicitu
if duplicity_paths:
lb.selection_set(0)
lb.event_generate("<<ListboxSelect>>")
# ── Zavření ───────────────────────────────────────────────────────────────
def on_close():
pane_orig.close()
pane_dup.close()
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_close)
# ── Geometrie pro rename_dialog ───────────────────────────────────────────
root.update_idletasks()
if write_geom:
_y = root.winfo_y()
_h = root.winfo_height()
write_geom.write_text(
json.dumps({"x": WIN_X, "y": _y, "w": WIN_W, "h": _h}),
encoding="utf-8"
)
root.lift()
root.attributes("-topmost", True)
root.after(1500, lambda: root.attributes("-topmost", False))
root.mainloop()
if __name__ == "__main__":
main()
@@ -0,0 +1,763 @@
"""
Zpracování naskenovaných PDF — nová verze.
1. Preview originálu + Claude Vision API
2. Rename dialog
3. 5 variant komprese → uživatel vybere
4. Uložit do Processed, smazat originál
"""
import base64
import gc
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
import anthropic
from pdf2image import convert_from_path
sys.path.insert(0, str(Path(__file__).parent.parent))
from Knihovny.najdi_dropbox import get_dropbox_root
FB_CONFIG = {
'dsn': r'reporter:c:\medicus\medicus.fdb',
'user': 'SYSDBA',
'password': 'masterkey',
'charset': 'win1250',
}
def _load_env():
env_path = Path(__file__).parent.parent / ".env"
if env_path.exists():
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
_load_env()
def _find_poppler() -> str | None:
candidates = [
r"C:/Poppler/Library/bin",
str(Path.home() / r"scoop\apps\poppler\current\Library\bin"),
str(Path.home() / r"scoop\apps\poppler\current\bin"),
]
for p in candidates:
if Path(p).exists():
return p
pdfinfo = shutil.which("pdfinfo")
if pdfinfo:
return str(Path(pdfinfo).parent)
return None
POPPLER_PATH = _find_poppler()
CORRECTIONS = True # True = corrections.json se načítá a ukládá; False = ignorovat
_DROPBOX = Path(get_dropbox_root())
TO_PROCESS = _DROPBOX / r"Ordinace\Dokumentace_ke_zpracování\Ricoh Fi-8040\KeZpracování"
PROCESSED = _DROPBOX / r"Ordinace\Dokumentace_ke_zpracování\Ricoh Fi-8040\Zpracováno"
CORRECTIONS_FILE = Path(__file__).parent / "corrections.json"
NAMING_RULES_FILE = Path(__file__).parent / "naming_rules.md"
DOKUMENTACE = _DROPBOX / r"Ordinace\Dokumentace_zpracovaná"
import threading
_dokumentace_index: set[str] = set()
_dokumentace_ready = threading.Event()
def _load_dokumentace_index_bg():
if DOKUMENTACE.exists():
names = {f.name for f in DOKUMENTACE.iterdir() if f.is_file()}
else:
names = set()
global _dokumentace_index
_dokumentace_index = names
_dokumentace_ready.set()
print(f" Index dokumentace: {len(names)} souborů načteno.")
def start_dokumentace_index():
t = threading.Thread(target=_load_dokumentace_index_bg, daemon=True)
t.start()
VIEWER = Path(__file__).parent / "preview_viewer.py"
RENAME_DIALOG = Path(__file__).parent / "rename_dialog.py"
VARIANT_PICKER = Path(__file__).parent / "variant_picker.py"
# 5 kompresních variant
COMPRESS_VARIANTS = [
("300 DPI / q90", 300, 90),
("200 DPI / q85", 200, 85),
("150 DPI / q80", 150, 80),
("120 DPI / q75", 120, 75),
( "96 DPI / q70", 96, 70),
]
# ─── Komprese jedné varianty ──────────────────────────────────────────────────
def set_single_page_view(pdf_path: Path):
from pikepdf import Pdf, Name
with Pdf.open(str(pdf_path), allow_overwriting_input=True) as pdf:
pdf.Root.PageLayout = Name("/SinglePage")
pdf.Root.PageMode = Name("/UseNone")
pdf.save()
def compress_to_temp(pdf_path: Path, dpi: int, quality: int) -> Path:
import fitz
src = fitz.open(str(pdf_path))
mat = fitz.Matrix(dpi / 72.0, dpi / 72.0)
out = fitz.open()
for page in src:
pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB)
img_bytes = pix.tobytes("jpeg", jpg_quality=quality)
img_doc = fitz.open("pdf", fitz.open("jpeg", img_bytes).convert_to_pdf())
rect = page.rect
np = out.new_page(width=rect.width, height=rect.height)
np.show_pdf_page(np.rect, img_doc, 0)
src.close()
tmp = Path(tempfile.mktemp(suffix=".pdf"))
out.save(tmp, deflate=True, garbage=4)
out.close()
return tmp
# ─── Medicus ověření ─────────────────────────────────────────────────────────
def _medicus_connect():
try:
import fdb
return fdb.connect(**FB_CONFIG)
except Exception as e:
print(f" [Medicus] Nepřipojeno: {e}")
return None
def _lookup_by_rc(cur, rc_digits: str) -> dict | None:
cur.execute(
"SELECT IDPAC, PRIJMENI, JMENO, RODCIS FROM KAR "
"WHERE REPLACE(RODCIS, '/', '') = ?", (rc_digits,)
)
row = cur.fetchone()
if row:
return {"idpac": row[0], "prijmeni": row[1].strip(), "jmeno": row[2].strip(), "rodcis": row[3].strip()}
return None
def _rc_candidates(rc: str) -> list[str]:
similar = {"0": "8", "8": "0", "1": "7", "7": "1", "5": "6", "6": "5", "3": "8"}
candidates = set()
for i in range(len(rc)):
candidates.add(rc[:i] + rc[i+1:])
for i in range(len(rc) + 1):
candidates.add(rc[:i] + "0" + rc[i:])
for i, ch in enumerate(rc):
if ch in similar:
candidates.add(rc[:i] + similar[ch] + rc[i+1:])
candidates.discard(rc)
return sorted(c for c in candidates if len(c) in (9, 10))
def _rc_checksum_ok(rc: str) -> bool:
digits = re.sub(r"\D", "", rc)
if len(digits) == 10:
return int(digits) % 11 == 0
return True
def _parse_jmeno_prijmeni(name_str: str) -> tuple[str, str] | None:
"""Parsuje 'Příjmení, Jméno' nebo 'Příjmení Jméno' -> (prijmeni, jmeno)."""
name_str = name_str.strip()
if "," in name_str:
parts = [p.strip() for p in name_str.split(",", 1)]
if len(parts) == 2 and parts[0] and parts[1]:
return parts[0], parts[1]
parts = name_str.split()
if len(parts) >= 2:
return parts[0], " ".join(parts[1:])
return None
def _lookup_by_name(cur, prijmeni: str, jmeno: str) -> list[dict]:
"""Vyhledá pacienty v KAR podle příjmení a prvního slova jména."""
jmeno_first = jmeno.split()[0] if jmeno.split() else jmeno
cur.execute(
"SELECT IDPAC, PRIJMENI, JMENO, RODCIS FROM KAR "
"WHERE UPPER(PRIJMENI) = UPPER(?)",
(prijmeni,)
)
rows = cur.fetchall()
result = []
for row in rows:
db_jmeno = (row[2] or "").strip().upper()
if db_jmeno.startswith(jmeno_first.upper()):
result.append({
"idpac": row[0],
"prijmeni": row[1].strip(),
"jmeno": row[2].strip(),
"rodcis": row[3].strip(),
})
return result
def verify_patient_by_name(name_str: str) -> dict:
"""Vyhledá pacienta v Medicus podle jména — fallback když RČ chybí."""
parsed = _parse_jmeno_prijmeni(name_str)
if not parsed:
return {"status": "not_found", "patient": None, "rc_corrected": None}
prijmeni, jmeno = parsed
con = _medicus_connect()
if con is None:
return {"status": "offline", "patient": None, "rc_corrected": None}
try:
cur = con.cursor()
matches = _lookup_by_name(cur, prijmeni, jmeno)
if not matches:
return {"status": "not_found", "patient": None, "rc_corrected": None}
if len(matches) == 1:
return {"status": "by_name", "patient": matches[0], "rc_corrected": None}
return {"status": "by_name_multi", "patient": matches[0], "rc_corrected": None, "all_matches": matches}
finally:
con.close()
def verify_patient(rc_raw: str) -> dict:
rc = re.sub(r"\D", "", rc_raw or "")
if not rc:
return {"status": "not_found", "patient": None, "rc_corrected": None}
con = _medicus_connect()
if con is None:
return {"status": "offline", "patient": None, "rc_corrected": None}
try:
cur = con.cursor()
patient = _lookup_by_rc(cur, rc)
if patient:
return {"status": "ok", "patient": patient, "rc_corrected": None}
candidates = _rc_candidates(rc)
matches = [(c, _lookup_by_rc(cur, c)) for c in candidates]
matches = [(c, p) for c, p in matches if p]
if not matches:
return {"status": "not_found", "patient": None, "rc_corrected": None}
matches.sort(key=lambda x: (0 if _rc_checksum_ok(x[0]) else 1))
best_rc, best_patient = matches[0]
return {"status": "fuzzy", "patient": best_patient, "rc_corrected": best_rc, "all_matches": matches}
finally:
con.close()
def check_duplicates(rc: str, datum: str) -> list[str]:
if not rc or not datum:
return []
# Počkej max 15s na dokončení indexu (typicky hotovo za dobu volání Claude)
_dokumentace_ready.wait(timeout=15)
prefix = f"{rc} {datum}"
return [name for name in _dokumentace_index if name.startswith(prefix)]
# ─── EKG zpracování ──────────────────────────────────────────────────────────
_EKG_FLAG = "rotated-by-script"
def _is_ekg(pdf_path: Path) -> bool:
"""Detekuje EKG PDF podle metadat — PDFCreator 2.4.x je specifický pro EKG přístroj."""
if pdf_path.suffix.lower() != ".pdf":
return False
try:
import fitz
doc = fitz.open(str(pdf_path))
meta = doc.metadata
doc.close()
haystack = " ".join(filter(None, [
meta.get("creator", ""), meta.get("producer", "")
])).lower()
return "pdfcreator" in haystack
except Exception:
return False
def _ekg_rotate_if_needed(pdf_path: Path):
"""Otočí první stránku o 90° CW a odstraní případnou druhou stránku."""
import fitz
doc = fitz.open(str(pdf_path))
meta = doc.metadata
keywords = meta.get("keywords", "") or ""
if _EKG_FLAG in keywords:
doc.close()
return
page = doc[0]
page.set_rotation((page.rotation + 90) % 360)
if doc.page_count > 1:
doc.delete_page(1)
meta["keywords"] = (keywords + " " + _EKG_FLAG).strip()
doc.set_metadata(meta)
tmp = pdf_path.with_suffix(".tmp.pdf")
doc.save(tmp, deflate=True)
doc.close()
os.replace(tmp, pdf_path)
print(" [EKG] Stránka otočena o 90°.")
def _ekg_ocr(pdf_path: Path) -> str:
import fitz
import pytesseract
from PIL import Image as _PILImage
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
doc = fitz.open(str(pdf_path))
pix = doc[0].get_pixmap(dpi=300)
img = _PILImage.frombytes("RGB", [pix.width, pix.height], pix.samples)
doc.close()
return pytesseract.image_to_string(img, lang="ces", config="--psm 6")
def _ekg_extract_rc(text: str) -> str | None:
m = re.search(r"(\d{6})\s*/?\s*(\d{3,4})", text)
if not m:
return None
return m.group(1) + m.group(2).zfill(4)
def _ekg_extract_date(text: str) -> str | None:
"""Vrátí datum ve formátu YYYY-MM-DD nebo None."""
m = re.search(r"(\d{1,2})[\.,]\s*(\d{1,2})[\.,]\s*(\d{4})", text)
if m:
d, mo, y = m.groups()
return f"{y}-{mo.zfill(2)}-{d.zfill(2)}"
for pat in [r"\b(\d{2})(\d{2})(\d{4})\b", r"\b(\d{2})(\d{1})(\d{4})\b"]:
for m in re.finditer(pat, text):
d, mo, y = m.groups()
if 1 <= int(d) <= 31 and 1 <= int(mo) <= 12 and 1900 <= int(y) <= 2100:
return f"{y}-{mo.zfill(2)}-{d.zfill(2)}"
return None
def extract_info_ekg(pdf_path: Path) -> dict:
"""EKG větev: rotace in-place, Tesseract OCR, Medicus ověření."""
_ekg_rotate_if_needed(pdf_path)
print(" [EKG] OCR přes Tesseract...")
raw_text = _ekg_ocr(pdf_path)
print(f"\n--- EKG OCR TEXT ---\n{raw_text}\n--- KONEC ---\n")
rc_ocr = _ekg_extract_rc(raw_text)
date_iso = _ekg_extract_date(raw_text)
print(f" [EKG] RČ: {rc_ocr or 'NENALEZENO'} | Datum: {date_iso or 'NENALEZENO'}")
print(f" [EKG] Ověřuji v Medicus (RČ: {rc_ocr or '?'})...")
verif = verify_patient(rc_ocr or "")
rc_final = rc_ocr
if verif["status"] == "fuzzy" and verif.get("rc_corrected"):
rc_final = verif["rc_corrected"]
print(f" [EKG] RČ opraveno: {rc_ocr}{rc_final}")
patient = verif.get("patient")
name_part = f"{patient['prijmeni']}, {patient['jmeno']}" if patient else ""
if rc_final and date_iso:
nazev = f"{rc_final} {date_iso}{(' ' + name_part) if name_part else ''} [EKG] [bez hodnocení].pdf"
else:
nazev = None
return {
"rodne_cislo": rc_final,
"datum_zpravy": date_iso,
"nazev_souboru": nazev,
"_verif": verif,
"_rc_ocr": rc_ocr or "",
}
# ─── Korekce (few-shot příklady) ─────────────────────────────────────────────
def load_corrections() -> list[dict]:
if not CORRECTIONS_FILE.exists():
return []
content = CORRECTIONS_FILE.read_text(encoding="utf-8")
idx = content.find('[')
if idx < 0:
return []
try:
result = json.loads(content[idx:])
# Pokud byl soubor poškozený (garbage před [), přepiš ho čistě
if idx > 0:
CORRECTIONS_FILE.write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
return result
except json.JSONDecodeError:
return []
def save_correction(original: str, corrected: str):
if not CORRECTIONS:
return
corrections = load_corrections()
for c in corrections:
if c["original"] == original and c["corrected"] == corrected:
return
corrections.append({"original": original, "corrected": corrected})
CORRECTIONS_FILE.write_text(
json.dumps(corrections, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f" ✓ Korekce uložena ({len(corrections)} celkem)")
def load_naming_rules() -> str:
if NAMING_RULES_FILE.exists():
content = NAMING_RULES_FILE.read_text(encoding="utf-8").strip()
if content:
return f"Pravidla pro pojmenování souborů (dodržuj vždy):\n{content}\n\n"
return ""
def build_corrections_prompt() -> str:
if not CORRECTIONS:
return ""
corrections = load_corrections()
if not corrections:
return ""
lines = ["Příklady korekcí z minulých běhů (uč se z nich):"]
for c in corrections[-10:]:
lines.append(f' - špatně: "{c["original"]}"')
lines.append(f' správně: "{c["corrected"]}"')
return "\n".join(lines) + "\n\n"
# ─── Claude Vision API ────────────────────────────────────────────────────────
def extract_info(pdf_path: Path, known_patient: str | None = None, known_rc: str | None = None) -> dict:
print(" Převádím na obrázek...")
suffix = pdf_path.suffix.lower()
if suffix in (".jpg", ".jpeg", ".png"):
from PIL import Image
img = Image.open(pdf_path)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=95)
img.close()
else:
from PIL import Image as _PILImage
_PILImage.MAX_IMAGE_PIXELS = None # vypni limit pro naše vlastní PDFs
images = convert_from_path(str(pdf_path), poppler_path=POPPLER_PATH, dpi=300)
buf = io.BytesIO()
images[0].save(buf, format="JPEG", quality=95)
del images
gc.collect()
image_b64 = base64.standard_b64encode(buf.getvalue()).decode("utf-8")
if known_patient and known_rc:
# Identita pacienta je známa z názvu souboru — Claude se soustředí jen na obsah zprávy
patient_hint = (
f"Pacient je již znám: RČ={known_rc}, jméno={known_patient}. "
f"Pole \"jmeno\" nastav na \"{known_patient}\" a \"rodne_cislo\" na \"{known_rc}\". "
f"Soustřeď se hlavně na datum zprávy, typ dokumentu a klinickou poznámku.\n"
)
nazev_format = (
f"\"{known_rc} {{datum_zpravy}} {known_patient} [{{typ_dokumentu}}] [{{poznamka}}].pdf\""
)
else:
patient_hint = ""
nazev_format = (
"\"{rodne_cislo} {datum_zpravy} {Příjmení}, {Jméno} [{typ_dokumentu}] [{poznamka}].pdf\" "
"(jméno bez titulu, RČ bez lomítka)"
)
prompt = (
load_naming_rules() +
build_corrections_prompt() +
patient_hint +
"Toto je naskenovaná lékařská zpráva v češtině. "
"Vrať JSON s těmito poli:\n"
"- \"jmeno\": celé jméno pacienta (příjmení + jméno + případný titul)\n"
"- \"rodne_cislo\": rodné číslo pacienta BEZ lomítka (pouze číslice)\n"
"- \"datum_zpravy\": datum zprávy ve formátu YYYY-MM-DD\n"
"- \"typ_dokumentu\": typ dokumentu — "
"\"LZ {oddělení}\" = ambulantní/lékařská zpráva (např. \"LZ chirurgie\", \"LZ kardiologie\", \"LZ plicní\", \"LZ ORL\"); "
"\"PZ {oddělení}\" = propouštěcí zpráva z hospitalizace (např. \"PZ interna\", \"PZ neurologie\"). "
"Jiné typy: \"Laboratoř\", \"CT břicha\", \"MRI páteře\", \"kolonoskopie\", "
"\"operační protokol oční\", \"poukaz FT\", \"diagnostická mamografie\" atd.\n"
"- \"poznamka\": krátká klinická poznámka česky, max 80 znaků. "
"DŮLEŽITÉ: pokud zpráva obsahuje sekci \"Závěr:\" nebo \"Závěr vyšetření:\", "
"použij VÝHRADNĚ obsah této sekce — je nejdůležitější. "
"Teprve pokud závěr chybí, shrň obsah z celé zprávy.\n"
f"- \"nazev_souboru\": název souboru ve formátu {nazev_format}\n"
"- \"rotace\": o kolik stupňů CCW je třeba otočit obrázek aby byl text čitelně na výšku nebo šířku "
"(hodnoty: 0, 90, 180, 270). Pokud je text již správně orientovaný, vrať 0.\n\n"
"Pokud pole nenajdeš, použij null. Nepiš nic jiného než JSON."
)
print(" Volám Claude Vision API...")
try:
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=400,
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": image_b64}},
{"type": "text", "text": prompt},
]}],
)
usage = response.usage
print(f" Tokeny: {usage.input_tokens} in + {usage.output_tokens} out = ${usage.input_tokens*3/1e6 + usage.output_tokens*15/1e6:.4f}")
raw = response.content[0].text.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
try:
return json.loads(raw.strip())
except json.JSONDecodeError:
print(f" VAROVÁNÍ: nelze parsovat JSON: {raw!r}")
return {"nazev_souboru": None, "raw": raw}
except Exception as e:
print(f" VAROVÁNÍ: Claude API selhalo ({e}) — otevírám dialog pro ruční vyplnění.")
return {"nazev_souboru": None}
# ─── Subprocess helpers ───────────────────────────────────────────────────────
def open_preview(pdf_path: Path) -> tuple[subprocess.Popen, Path]:
geom_file = Path(tempfile.mktemp(suffix=".json"))
proc = subprocess.Popen(
[sys.executable, str(VIEWER), str(pdf_path), f"--write-geometry={geom_file}"],
stderr=subprocess.PIPE,
)
return proc, geom_file
def read_preview_bottom(geom_file: Path, timeout: float = 5.0) -> int:
import time
deadline = time.time() + timeout
while time.time() < deadline:
if geom_file.exists():
geom = json.loads(geom_file.read_text(encoding="utf-8"))
geom_file.unlink(missing_ok=True)
return geom["y"] + geom["h"] + 30 # +30 pro title bar
time.sleep(0.1)
geom_file.unlink(missing_ok=True)
return None
def run_rename_dialog(nazev: str, info_lines: list, below_y: int = None) -> str | None:
tmp = Path(tempfile.mktemp(suffix=".json"))
tmp.write_text(json.dumps({"nazev": nazev, "info_lines": info_lines}, ensure_ascii=False), encoding="utf-8")
args = [sys.executable, str(RENAME_DIALOG), str(tmp)]
if below_y is not None:
args.append(f"--below-y={below_y}")
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
proc = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", env=env)
tmp.unlink(missing_ok=True)
if proc.stderr.strip():
print(f" [rename_dialog] CHYBA:\n{proc.stderr.strip()}")
out = proc.stdout.strip()
return json.loads(out).get("value") if out else None
def run_variant_picker(variants_data: list) -> str | None:
tmp = Path(tempfile.mktemp(suffix=".json"))
tmp.write_text(json.dumps(variants_data, ensure_ascii=False), encoding="utf-8")
proc = subprocess.run(
[sys.executable, str(VARIANT_PICKER), str(tmp)],
capture_output=True, text=True, encoding="utf-8",
)
tmp.unlink(missing_ok=True)
if proc.returncode != 0 or not proc.stdout.strip():
print(f" [variant_picker] returncode={proc.returncode}")
if proc.stderr.strip():
print(f" [variant_picker] CHYBA:\n{proc.stderr.strip()}")
out = proc.stdout.strip()
return json.loads(out).get("chosen") if out else None
# ─── Detekce split názvu ──────────────────────────────────────────────────────
# Vzor: "7952090443 Kalousová, Eva split_001.pdf"
_SPLIT_RE = re.compile(r"^(\d{9,10})\s+(.+?)\s+split_\d+\.pdf$", re.IGNORECASE)
def _parse_split_filename(name: str) -> tuple[str, str] | None:
"""Vrátí (rc_digits, 'Příjmení, Jméno') nebo None."""
m = _SPLIT_RE.match(name)
if m:
return m.group(1), m.group(2)
return None
# ─── Hlavní flow ──────────────────────────────────────────────────────────────
def process_file(pdf_path: Path):
print(f"\nSoubor: {pdf_path.name}")
# Spusť načítání indexu dokumentace na pozadí — hotovo za dobu volání Claude/OCR
start_dokumentace_index()
is_ekg = _is_ekg(pdf_path)
split = None
if is_ekg:
# EKG větev: rotace in-place PŘED preview, pak Tesseract OCR + Medicus
print(" [EKG] Detekován EKG soubor (PDFCreator).")
info = extract_info_ekg(pdf_path)
nazev = info.get("nazev_souboru") or pdf_path.name
rc_from_scan = re.sub(r"\D", "", info.get("rodne_cislo") or "")
verif = info["_verif"]
rc_ocr = info["_rc_ocr"]
# 1. Otevři preview (pro EKG: soubor je již otočen)
preview, geom_file = open_preview(pdf_path)
below_y = read_preview_bottom(geom_file)
if not is_ekg:
# 2. Zjisti RČ a jméno — buď z názvu (split soubor) nebo přes Claude Vision API
split = _parse_split_filename(pdf_path.name)
if split:
rc_from_scan, name_from_filename = split
print(f" Split soubor — RČ z názvu: {rc_from_scan}, jméno: {name_from_filename}")
info = extract_info(pdf_path, known_patient=name_from_filename, known_rc=rc_from_scan)
nazev = info.get("nazev_souboru") or pdf_path.name
nazev = re.sub(r"^\d{9,10}\s+", f"{rc_from_scan} ", nazev)
else:
info = extract_info(pdf_path)
nazev = info.get("nazev_souboru") or pdf_path.name
rc_from_scan = re.sub(r"\D", "", info.get("rodne_cislo") or "")
# 3. Medicus ověření + fuzzy matching RČ
print(f" Ověřuji v Medicus (RČ: {rc_from_scan})...")
verif = verify_patient(rc_from_scan)
rc_ocr = rc_from_scan
# Oprava RČ při fuzzy matchi (jen pro nesplit soubory — u split máme RC spolehlivé)
if not split and verif["status"] == "fuzzy" and verif.get("rc_corrected") and nazev:
nazev = nazev.replace(rc_from_scan, verif["rc_corrected"], 1)
print(f" → RČ opraveno: {rc_from_scan}{verif['rc_corrected']}")
# Fallback: RČ nenalezeno → zkus vyhledat podle jména z Claude
if verif["status"] == "not_found" and info.get("jmeno"):
jmeno_z_claude = info["jmeno"]
print(f" RČ nenalezeno, zkouším vyhledat dle jména: {jmeno_z_claude}")
verif_name = verify_patient_by_name(jmeno_z_claude)
if verif_name["status"] in ("by_name", "by_name_multi"):
verif = verif_name
rc_z_medicus = re.sub(r"\D", "", verif["patient"]["rodcis"])
p = verif["patient"]
print(f" → Nalezeno dle jména: {p['prijmeni']} {p['jmeno']} | RČ {p['rodcis']}")
if verif_name["status"] == "by_name_multi":
print(f" ⚠ Více shod ({len(verif_name['all_matches'])}) — přijat první výsledek")
# Aktualizuj RČ v navrženém názvu
if nazev and rc_z_medicus:
nazev = re.sub(r"^null\s*", rc_z_medicus + " ", nazev)
if not nazev.startswith(rc_z_medicus):
nazev = re.sub(r"^\S+\s*", rc_z_medicus + " ", nazev)
# Info řádky pro dialog
status = verif["status"]
patient = verif.get("patient")
info_lines = []
if is_ekg:
info_lines.append("⚡ EKG soubor — Tesseract OCR")
elif split:
info_lines.append(f"⚡ Split soubor — identita z názvu: {name_from_filename} | RČ {rc_from_scan}")
if status == "ok":
info_lines.append(f"✓ Medicus: {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}")
elif status == "fuzzy":
info_lines.append(f"⚠ RČ ze skenu '{rc_ocr}' → opraveno na {verif['rc_corrected']}")
info_lines.append(f" Pacient: {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}")
elif status == "by_name":
info_lines.append(f"✓ Nalezeno dle jména: {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}")
elif status == "by_name_multi":
count = len(verif.get("all_matches", []))
info_lines.append(f"⚠ Nalezeno dle jména ({count} shod, 1. výsledek): {patient['prijmeni']} {patient['jmeno']} | RČ {patient['rodcis']}")
elif status == "not_found":
info_lines.append(f"✗ RČ '{rc_ocr}' nenalezeno v Medicus")
else:
info_lines.append("— Medicus nedostupný (offline)")
# Duplicity
rc_final = re.sub(r"\D", "", verif["patient"]["rodcis"] if patient else rc_from_scan)
duplicity = check_duplicates(rc_final, info.get("datum_zpravy") or "")
if duplicity:
info_lines.append(f"⚠ DUPLICITA: {', '.join(duplicity)}")
if not info_lines:
info_lines = ["[uprav ručně]"]
print(" Otevírám dialog pro schválení názvu...")
final_name = run_rename_dialog(nazev, info_lines, below_y=below_y)
preview.terminate()
stderr_out = preview.stderr.read().decode("utf-8", errors="replace").strip() if preview.stderr else ""
if stderr_out:
print(f" [preview] CHYBA: {stderr_out}")
if not final_name:
print(" Přeskočeno.")
return
if not final_name.endswith(".pdf"):
final_name += ".pdf"
final_name = re.sub(r'[<>:"/\\|?*]', '', final_name)
if nazev and final_name != nazev:
save_correction(nazev, final_name)
print(f" Schválený název: {final_name}")
# 4. Generuj kompresní varianty (originál + 5 variant)
print(" Generuji kompresní varianty...")
temp_files = []
orig_kb = round(pdf_path.stat().st_size / 1024)
variants_data = [{"path": str(pdf_path), "label": "Originál", "size_kb": orig_kb}]
for label, dpi, quality in COMPRESS_VARIANTS:
tmp = compress_to_temp(pdf_path, dpi, quality)
size_kb = round(tmp.stat().st_size / 1024)
temp_files.append(tmp)
variants_data.append({"path": str(tmp), "label": label, "size_kb": size_kb})
print(f" {label}: {size_kb} kB")
# 5. Vyber variantu
print(" Vyber variantu v okně...")
chosen = run_variant_picker(variants_data)
if not chosen:
print(" Žádná varianta nevybrána, přeskakuji.")
for t in temp_files:
t.unlink(missing_ok=True)
return
# 6. Ulož do Processed
PROCESSED.mkdir(exist_ok=True)
dest = PROCESSED / final_name
if dest.exists():
print(f" Přepisuji existující: {dest.name}")
shutil.copy2(chosen, dest)
set_single_page_view(dest)
pdf_path.unlink()
print(f" ✓ Uloženo: {dest.name}")
for t in temp_files:
t.unlink(missing_ok=True) # originál mezi temp_files není, je bezpečné
def process_folder(folder: Path):
files = sorted(f for f in folder.iterdir() if f.suffix.lower() in (".pdf", ".jpg", ".jpeg", ".png"))
if not files:
print(f"Žádné soubory v: {folder}")
return
print(f"Nalezeno {len(files)} soubor(ů).")
for f in files:
try:
process_file(f)
except Exception as e:
print(f" CHYBA: {e}")
print("\nHotovo.")
if __name__ == "__main__":
PROCESSED.mkdir(exist_ok=True)
TO_PROCESS.mkdir(exist_ok=True)
target = Path(sys.argv[1]) if len(sys.argv) > 1 else TO_PROCESS
if target.is_file():
process_file(target)
elif target.is_dir():
process_folder(target)
else:
print("Použití: python extract_patient_info_novy.py [soubor.pdf nebo složka]")
sys.exit(1)
@@ -0,0 +1,101 @@
"""
Konverze JPG/PNG → PDF se správnou orientací stránky (A4).
Řeší:
- EXIF orientaci (fotky z telefonu/skeneru bývají otočené)
- Správné umístění na A4 stránce (na výšku nebo na šířku dle obsahu)
- Zachování kvality
Použití:
python jpg_to_pdf.py soubor.jpg
python jpg_to_pdf.py soubor.jpg vystup.pdf
"""
import io
import sys
from pathlib import Path
from PIL import Image, ImageOps
# A4 rozměry v mm
A4_W_MM = 210
A4_H_MM = 297
MARGIN_MM = 0 # bez okraje, tisk si řeší Acrobat (Fit to Print)
def fix_orientation(img: Image.Image) -> Image.Image:
"""Opraví rotaci podle EXIF dat (tag 274)."""
return ImageOps.exif_transpose(img)
def image_to_pdf(src: Path, dst: Path, dpi: int = 150, quality: int = 80, rotate_ccw: int = 0):
img = Image.open(src)
print(f" Originál: {img.size[0]}×{img.size[1]} px, mode={img.mode}, format={img.format}")
# 1. Oprav EXIF orientaci
img = fix_orientation(img)
print(f" Po EXIF korekci: {img.size[0]}×{img.size[1]} px")
# 2. Rotace dle parametru (od Claude nebo ručně)
if rotate_ccw and rotate_ccw != 0:
img = img.rotate(rotate_ccw, expand=True)
print(f" Po rotaci {rotate_ccw}° CCW: {img.size[0]}×{img.size[1]} px")
# 2. Převeď na RGB (PDF nepodporuje RGBA/P)
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
# 3. Urči orientaci stránky podle poměru stran obrázku
img_w, img_h = img.size
if img_w > img_h:
# Obrázek na šířku → stránka na šířku (A4 landscape)
page_w_mm, page_h_mm = A4_H_MM, A4_W_MM
print(f" Orientace stránky: na šířku (landscape)")
else:
# Obrázek na výšku → stránka na výšku (A4 portrait)
page_w_mm, page_h_mm = A4_W_MM, A4_H_MM
print(f" Orientace stránky: na výšku (portrait)")
# 4. Vypočti cílovou velikost s okrajem (mm → px při daném DPI)
mm_to_px = dpi / 25.4
max_w_px = int((page_w_mm - 2 * MARGIN_MM) * mm_to_px)
max_h_px = int((page_h_mm - 2 * MARGIN_MM) * mm_to_px)
# 5. Škáluj obrázek na stránku (zachovej poměr stran)
img.thumbnail((max_w_px, max_h_px), Image.LANCZOS)
print(f" Výsledná velikost obrázku: {img.size[0]}×{img.size[1]} px")
# 6. Vlož obrázek na bílé A4 plátno
page_w_px = int(page_w_mm * mm_to_px)
page_h_px = int(page_h_mm * mm_to_px)
canvas = Image.new("RGB", (page_w_px, page_h_px), "white")
offset_x = (page_w_px - img.size[0]) // 2
offset_y = (page_h_px - img.size[1]) // 2
canvas.paste(img, (offset_x, offset_y))
# 7. Ulož jako PDF
canvas.save(dst, "PDF", resolution=dpi, quality=quality)
print(f" ✓ Uloženo: {dst.name} ({dst.stat().st_size // 1024} KB)")
if __name__ == "__main__":
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
if len(sys.argv) < 2:
print("Použití: python jpg_to_pdf.py soubor.jpg [vystup.pdf] [rotace_ccw]")
print(" rotace_ccw: 0 / 90 / 180 / 270 (výchozí: 0)")
sys.exit(1)
src = Path(sys.argv[1])
if not src.exists():
print(f"Soubor nenalezen: {src}")
sys.exit(1)
dst = Path(sys.argv[2]) if len(sys.argv) > 2 else src.with_suffix(".pdf")
rotate_ccw = int(sys.argv[3]) if len(sys.argv) > 3 else 0
print(f"Konvertuji: {src.name}{dst.name}")
image_to_pdf(src, dst, rotate_ccw=rotate_ccw)
@@ -0,0 +1,462 @@
"""
Hlavní okno pro zpracování naskenovaných dokumentů.
Kombinuje náhled originálu, náhled duplicit, listbox duplicit a rename panel.
Spouští se jako subprocess z extract_patient_info_novy_test.py.
Argumenty: main_viewer.py <json_soubor>
JSON vstup: {
"original": "cesta/k/originalu.pdf",
"duplicity": ["cesta1.pdf", ...], # plné cesty
"labels": ["název1.pdf", ...], # zobrazované názvy
"nazev": "navrzeny_nazev.pdf",
"info_lines": ["✓ Medicus: ...", ...],
"varianty": ["varianta1.pdf", ...]
}
JSON výstup (stdout): { "value": "schvaleny nazev" } nebo { "value": null }
"""
import json
import sys
from pathlib import Path
import tkinter as tk
from tkinter import font as tkfont
if sys.platform == "win32":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
# ── PDF rendering ─────────────────────────────────────────────────────────────
def _open_doc(path_str: str):
import fitz
from PIL import Image
p = Path(path_str)
if not p.exists():
return None, None
suffix = p.suffix.lower()
if suffix in (".jpg", ".jpeg", ".png"):
return None, Image.open(p)
return fitz.open(str(p)), None
def _render(doc, pil_img, page_n: int, max_w: int, max_h: int):
import fitz
from PIL import Image
if doc is not None:
page = doc[page_n]
zoom = min(max_w / page.rect.width, max_h / page.rect.height)
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
elif pil_img is not None:
img = pil_img.copy()
img.thumbnail((max_w, max_h))
return img
return None
class PdfPane:
def __init__(self, parent, max_w: int, max_h: int, bg: str = "#1e1e2e", label_text: str = ""):
self.max_w = max_w
self.max_h = max_h
self.doc = None
self.pil_img = None
self.page_n = 0
self.page_count = 1
self.photo_ref = None
self.frame = tk.Frame(parent, bg=bg, width=max_w, height=max_h)
self.frame.pack_propagate(False)
if label_text:
tk.Label(self.frame, text=label_text, bg=bg, fg="#888",
font=("Segoe UI", 8, "bold")).pack(pady=(2, 0))
self.lbl_title = tk.Label(self.frame, text="", bg=bg, fg="#aaa",
font=("Segoe UI", 8), wraplength=max_w - 10)
self.lbl_title.pack(pady=(2, 0))
self.lbl_img = tk.Label(self.frame, bg=bg)
self.lbl_img.pack(fill="both", expand=True)
frame_nav = tk.Frame(self.frame, bg=bg)
frame_nav.pack(pady=2)
self.lbl_page = tk.Label(frame_nav, text="", bg=bg, fg="#aaa", font=("Segoe UI", 8))
self.lbl_page.pack(side="left", padx=4)
self.btn_prev = tk.Button(frame_nav, text="", bg="#333", fg="#fff",
relief="flat", padx=4, font=("Segoe UI", 8),
command=self._prev)
self.btn_prev.pack(side="left", padx=1)
self.btn_next = tk.Button(frame_nav, text="", bg="#333", fg="#fff",
relief="flat", padx=4, font=("Segoe UI", 8),
command=self._next)
self.btn_next.pack(side="left", padx=1)
def load(self, path_str: str, title: str = ""):
from PIL import ImageTk
if self.doc:
try: self.doc.close()
except Exception: pass
self.doc, self.pil_img = _open_doc(path_str)
self.page_count = len(self.doc) if self.doc else 1
self.lbl_title.config(text=title or Path(path_str).name, fg="#ccc")
self._show(0)
def clear(self, msg: str = ""):
if self.doc:
try: self.doc.close()
except Exception: pass
self.doc = None
self.pil_img = None
self.lbl_img.config(image="")
self.lbl_title.config(text=msg, fg="#555")
self.lbl_page.config(text="")
self.btn_prev.config(state="disabled")
self.btn_next.config(state="disabled")
self.photo_ref = None
def _show(self, n: int):
from PIL import ImageTk
self.page_n = n
img = _render(self.doc, self.pil_img, n, self.max_w - 10, self.max_h - 60)
if img:
self.photo_ref = ImageTk.PhotoImage(img)
self.lbl_img.config(image=self.photo_ref)
self.lbl_page.config(text=f"{n+1} / {self.page_count}" if self.page_count > 1 else "")
self.btn_prev.config(state="normal" if n > 0 else "disabled")
self.btn_next.config(state="normal" if n < self.page_count - 1 else "disabled")
def _prev(self):
if self.page_n > 0: self._show(self.page_n - 1)
def _next(self):
if self.page_n < self.page_count - 1: self._show(self.page_n + 1)
def close(self):
if self.doc:
try: self.doc.close()
except Exception: pass
# ── Pomocné funkce pro layout ─────────────────────────────────────────────────
def _get_layout() -> dict:
"""Načte layout oken pro aktuální hostname z layout_settings.json."""
import json as _json, socket as _socket
settings_file = Path(__file__).parent / "layout_settings.json"
hostname = _socket.gethostname().upper()
_default = {"duplicity_viewer": None}
if not settings_file.exists():
return _default
try:
settings = _json.loads(settings_file.read_text(encoding="utf-8"))
return settings.get(hostname, _default)
except Exception:
return _default
# ── Hlavní funkce ─────────────────────────────────────────────────────────────
def show(
original_path: str = "",
duplicity_paths: list = None,
labels: list = None,
nazev: str = "",
info_lines: list = None,
varianty: list = None,
) -> str | None:
"""
Zobrazí hlavní viewer přímo (bez subprocesů).
Vrátí schválený název souboru (bez .pdf) nebo None.
"""
duplicity_paths = duplicity_paths or []
labels = labels or [Path(p).name for p in duplicity_paths]
info_lines = info_lines or []
varianty = varianty or []
result = {"value": None}
try:
from PIL import Image, ImageTk
import fitz
except ImportError as e:
print(f"[main_viewer] Chybí knihovna: {e}", file=sys.stderr)
return None
# ── Layout ────────────────────────────────────────────────────────────────
try:
lw = _get_layout().get("duplicity_viewer") or {}
except Exception:
lw = {}
WIN_X = lw.get("x", 0)
WIN_Y = lw.get("y", 0)
WIN_W = lw.get("w", 3840)
WIN_H = lw.get("h", 1700)
BOTTOM_H = 260 # výška spodního panelu
TOP_H = WIN_H - BOTTOM_H - 8
GAP = 4 # mezera mezi náhledy (zmenšená)
LISTBOX_W = 560 # širší listbox duplicit
PANE_W = (WIN_W - LISTBOX_W - GAP - 20) // 2
BG = "#1a1a1a" # jednotné tmavé pozadí pro celé okno
COL_INFO = int(WIN_W * 0.15)
COL_MID = int(WIN_W * 0.45)
COL_VAR = WIN_W - COL_INFO - COL_MID
# ── Okno ──────────────────────────────────────────────────────────────────
root = tk.Tk()
root.tk.call("encoding", "system", "utf-8")
root.title("Zpracování dokumentu")
root.configure(bg=BG)
root.geometry(f"{WIN_W}x{WIN_H}+{WIN_X}+{WIN_Y}")
root.resizable(True, True)
# ═══════════════════════════════════════════════════════════════════════════
# HORNÍ ČÁST — náhledy + listbox
# ═══════════════════════════════════════════════════════════════════════════
frame_top = tk.Frame(root, bg=BG, height=TOP_H)
frame_top.pack(side="top", fill="x", expand=False)
frame_top.pack_propagate(False)
# Náhled originálu
pane_orig = PdfPane(frame_top, max_w=PANE_W, max_h=TOP_H,
bg=BG, label_text="ORIGINÁL")
pane_orig.frame.pack(side="left", fill="y", padx=(6, GAP), pady=4)
# Náhled duplicity
pane_dup = PdfPane(frame_top, max_w=PANE_W, max_h=TOP_H,
bg=BG, label_text="DUPLICITA")
pane_dup.frame.pack(side="left", fill="y", padx=(GAP, GAP), pady=4)
# Listbox duplicit vpravo
frame_lb = tk.Frame(frame_top, bg=BG, width=LISTBOX_W)
frame_lb.pack(side="left", fill="y", padx=(3, 6), pady=4)
frame_lb.pack_propagate(False)
tk.Label(frame_lb, text="Existující dokumenty:", anchor="w",
bg=BG, fg="#cc4444", font=("Segoe UI", 9, "bold")).pack(
fill="x", padx=6, pady=(8, 2))
sb_dup = tk.Scrollbar(frame_lb, orient="vertical")
txt_dup = tk.Text(
frame_lb, yscrollcommand=sb_dup.set,
font=("Segoe UI", 9), bg=BG, fg="#ddd",
bd=0, highlightthickness=0, relief="flat",
wrap="word", cursor="hand2", state="normal",
selectbackground="#ffffff", selectforeground="#000000",
)
sb_dup.config(command=txt_dup.yview)
sb_dup.pack(side="right", fill="y")
txt_dup.pack(side="left", fill="both", expand=True, padx=(6, 0))
# Tag pro výběr (zvýraznění vybraného řádku)
txt_dup.tag_config("selected", background="#ffffff", foreground="#000000")
txt_dup.tag_config("normal", background=BG, foreground="#ddd")
def _shorten_dup_label(name: str) -> str:
import re as _re
m = _re.match(r"\d{9,10}\s+(\d{4}-\d{2}-\d{2})\s+[^[]+(\[.+)", name)
if m:
return f"{m.group(1)} {m.group(2)}"
return name
dup_line_indices = [] # (line_start, line_end) pro každou duplicitu
if not duplicity_paths:
txt_dup.insert("end", "(žádné duplicity)")
txt_dup.config(state="disabled")
else:
for i, label in enumerate(labels):
short = _shorten_dup_label(Path(label).name if not Path(label).exists() else label)
line_start = txt_dup.index("end")
txt_dup.insert("end", short + "\n\n")
line_end = txt_dup.index("end")
dup_line_indices.append((line_start, line_end))
txt_dup.tag_add("normal", line_start, line_end)
txt_dup.config(state="disabled")
selected_dup = [None]
def _dup_click(event):
txt_dup.config(state="normal")
idx = txt_dup.index(f"@{event.x},{event.y}")
for i, (ls, le) in enumerate(dup_line_indices):
if txt_dup.compare(ls, "<=", idx) and txt_dup.compare(idx, "<", le):
# Odznač předchozí
if selected_dup[0] is not None:
prev_ls, prev_le = dup_line_indices[selected_dup[0]]
txt_dup.tag_remove("selected", prev_ls, prev_le)
txt_dup.tag_add("normal", prev_ls, prev_le)
# Označ nový
txt_dup.tag_remove("normal", ls, le)
txt_dup.tag_add("selected", ls, le)
selected_dup[0] = i
txt_dup.config(state="disabled")
# Načti PDF
if i < len(duplicity_paths):
pane_dup.load(duplicity_paths[i],
title=labels[i] if i < len(labels) else "")
return
txt_dup.config(state="disabled")
txt_dup.bind("<Button-1>", _dup_click)
# Oddělovač
tk.Frame(root, bg="#333", height=2).pack(fill="x")
# ═══════════════════════════════════════════════════════════════════════════
# SPODNÍ ČÁST — info | entry+tlačítka | návrhy
# ═══════════════════════════════════════════════════════════════════════════
frame_bot = tk.Frame(root, bg=BG, height=BOTTOM_H)
frame_bot.pack(side="top", fill="both", expand=True)
frame_bot.pack_propagate(False)
# ── Vlevo: info o pacientovi (15%) ────────────────────────────────────────
frame_info = tk.Frame(frame_bot, bg=BG, width=COL_INFO)
frame_info.pack(side="left", fill="y", padx=(10, 4), pady=8)
frame_info.pack_propagate(False)
tk.Label(frame_info, text="Informace o pacientovi", anchor="w",
bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x")
for line in info_lines:
color = "#cc4444" if line.startswith("") or line.startswith("") else \
"#44cc44" if line.startswith("") else "#aaa"
tk.Label(frame_info, text=line, anchor="w", bg=BG,
fg=color, font=("Segoe UI", 9), wraplength=COL_INFO - 20,
justify="left").pack(fill="x", pady=1)
# ── Uprostřed: název + tlačítka (45%) ────────────────────────────────────
frame_mid = tk.Frame(frame_bot, bg=BG, width=COL_MID)
frame_mid.pack(side="left", fill="y", padx=(4, 20), pady=8)
frame_mid.pack_propagate(False)
tk.Label(frame_mid, text="Název souboru (bez .pdf):", anchor="w",
bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x")
nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev
# Multiline Text widget — zalamuje dlouhé názvy
txt = tk.Text(frame_mid, font=("Segoe UI", 10), bg="#ffffff", fg="#000000",
insertbackground="#000000", relief="flat", height=3,
wrap="word", padx=6, pady=4, bd=0, highlightthickness=0)
txt.pack(fill="x", pady=(4, 0))
txt.insert("1.0", nazev_bez)
txt.mark_set("insert", "end")
txt.focus_set()
def get_txt_value() -> str:
return txt.get("1.0", "end").strip()
frame_btn = tk.Frame(frame_mid, bg=BG)
frame_btn.pack(pady=(6, 0))
def schvalit(event=None):
result["value"] = get_txt_value()
pane_orig.close()
pane_dup.close()
root.destroy()
def preskocit(event=None):
result["value"] = None
pane_orig.close()
pane_dup.close()
root.destroy()
tk.Button(frame_btn, text="✓ Schválit (Enter)", command=schvalit,
bg="#2a7a2a", fg="white", font=("Segoe UI", 10, "bold"),
padx=16, pady=6, relief="flat").pack(side="left", padx=8)
tk.Button(frame_btn, text="✗ Přeskočit (Esc)", command=preskocit,
bg="#7a2a2a", fg="white", font=("Segoe UI", 10),
padx=16, pady=6, relief="flat").pack(side="left", padx=8)
# Enter schvalí jen pokud není focus v Text widgetu (tam Enter = nový řádek)
root.bind("<Escape>", preskocit)
# Ctrl+Enter vždy schválí
root.bind("<Control-Return>", schvalit)
# ── Vpravo: návrhy od Claudea (40%) ──────────────────────────────────────
frame_right = tk.Frame(frame_bot, bg=BG, width=COL_VAR)
frame_right.pack(side="left", fill="y", padx=(4, 10), pady=8)
frame_right.pack_propagate(False)
tk.Label(frame_right, text="Návrhy pojmenování (kliknutím vyberte):", anchor="w",
bg=BG, fg="#888", font=("Segoe UI", 8, "bold")).pack(fill="x")
sb_var = tk.Scrollbar(frame_right, orient="vertical")
lb_var = tk.Listbox(
frame_right, yscrollcommand=sb_var.set,
font=("Segoe UI", 10), selectmode="single", activestyle="none",
bg=BG, fg="#ddd", bd=0, highlightthickness=0,
selectbackground="#ffffff", selectforeground="#000000",
cursor="hand2",
)
sb_var.config(command=lb_var.yview)
sb_var.pack(side="right", fill="y")
lb_var.pack(side="left", fill="both", expand=True)
for v in varianty:
v_bez = v[:-4] if v.endswith(".pdf") else v
lb_var.insert(tk.END, v_bez)
def on_varianta(event):
sel = lb_var.curselection()
if sel:
txt.delete("1.0", "end")
txt.insert("1.0", lb_var.get(sel[0]))
lb_var.bind("<<ListboxSelect>>", on_varianta)
# ═══════════════════════════════════════════════════════════════════════════
# Načtení dokumentů
# ═══════════════════════════════════════════════════════════════════════════
if original_path and Path(original_path).exists():
pane_orig.load(original_path, title=Path(original_path).name)
else:
pane_orig.clear(msg="(originál nedostupný)")
pane_dup.clear(msg="← vyberte duplicitu" if duplicity_paths else "(žádné duplicity)")
# Automaticky zobraz první duplicitu
if duplicity_paths:
pane_dup.load(duplicity_paths[0], title=labels[0] if labels else "")
if dup_line_indices:
txt_dup.config(state="normal")
ls, le = dup_line_indices[0]
txt_dup.tag_remove("normal", ls, le)
txt_dup.tag_add("selected", ls, le)
selected_dup[0] = 0
txt_dup.config(state="disabled")
# ── Zobrazení ─────────────────────────────────────────────────────────────
root.protocol("WM_DELETE_WINDOW", preskocit)
root.lift()
root.attributes("-topmost", True)
root.after(1500, lambda: root.attributes("-topmost", False))
root.mainloop()
return result["value"]
if __name__ == "__main__":
# Subprocess mód — čte data z JSON souboru předaného jako argument
if len(sys.argv) < 2:
print(json.dumps({"value": None}))
sys.exit(0)
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
value = show(
original_path = data.get("original") or "",
duplicity_paths = data.get("duplicity") or [],
labels = data.get("labels") or [],
nazev = data.get("nazev") or "",
info_lines = data.get("info_lines") or [],
varianty = data.get("varianty") or [],
)
print(json.dumps({"value": value}, ensure_ascii=False))
@@ -0,0 +1,22 @@
"""
Test skript — zobrazí počet monitorů, jejich rozlišení a který je primární.
"""
import sys
try:
from screeninfo import get_monitors
monitors = get_monitors()
print(f"Počet monitorů: {len(monitors)}\n")
for i, m in enumerate(monitors):
primary = " ← PRIMÁRNÍ" if getattr(m, "is_primary", False) else ""
print(f" Monitor {i+1}: {m.width}x{m.height} | pozice x={m.x}, y={m.y}{primary} | název: {getattr(m, 'name', '?')}")
except ImportError:
print("Knihovna 'screeninfo' není nainstalována — instaluji...")
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "screeninfo", "--break-system-packages", "-q"])
from screeninfo import get_monitors
monitors = get_monitors()
print(f"Počet monitorů: {len(monitors)}\n")
for i, m in enumerate(monitors):
primary = " ← PRIMÁRNÍ" if getattr(m, "is_primary", False) else ""
print(f" Monitor {i+1}: {m.width}x{m.height} | pozice x={m.x}, y={m.y}{primary} | název: {getattr(m, 'name', '?')}")
@@ -0,0 +1,144 @@
"""
Standalone PDF/obrázek náhled — spouští se jako subprocess z extract_patient_info.py.
Argumenty: preview_viewer.py <soubor> [--delete-on-close]
"""
import sys
from pathlib import Path
import tkinter as tk
def main():
if len(sys.argv) < 2:
sys.exit(1)
pdf_path = Path(sys.argv[1])
delete_on_close = "--delete-on-close" in sys.argv
try:
from PIL import Image, ImageTk
import fitz
except ImportError as e:
print(f"[preview_viewer] Chybí knihovna: {e}", file=sys.stderr)
sys.exit(2)
suffix = pdf_path.suffix.lower()
if suffix in (".jpg", ".jpeg", ".png"):
pil_img = Image.open(pdf_path)
doc = None
else:
doc = fitz.open(str(pdf_path))
pil_img = None
root = tk.Tk()
root.tk.call("encoding", "system", "utf-8")
sh = root.winfo_screenheight()
# Zjisti cílovou šířku z layoutu (pokud existuje), jinak výchozí 700px
try:
import sys as _sys_tmp
_sys_tmp.path.insert(0, str(Path(__file__).parent))
from window_layout import get_layout as _get_layout
_lw = _get_layout().get("preview_viewer")
RENDER_W = (_lw.get("w", 700) - 20) if _lw else 700
RENDER_H = (_lw.get("h", sh) - 80) if _lw else (sh - 150)
except Exception:
RENDER_W = 700
RENDER_H = sh - 150
page_count = len(doc) if doc else 1
current = [0]
photo_ref = [None]
def render(n) -> Image.Image:
if doc is not None:
page = doc[n]
zoom = min(RENDER_W / page.rect.width, RENDER_H / page.rect.height)
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
else:
img = pil_img.copy()
img.thumbnail((RENDER_W, RENDER_H), Image.LANCZOS)
return img
def on_close():
if doc:
try:
doc.close()
except Exception:
pass
if delete_on_close:
try:
pdf_path.unlink(missing_ok=True)
except Exception:
pass
root.destroy()
root.title(pdf_path.stem)
root.protocol("WM_DELETE_WINDOW", on_close)
lbl_img = tk.Label(root)
lbl_img.pack()
frame_nav = tk.Frame(root)
frame_nav.pack(pady=4)
lbl_page = tk.Label(frame_nav, font=("Segoe UI", 9))
lbl_page.pack(side="left", padx=10)
def show(n):
current[0] = n
img = render(n)
photo_ref[0] = ImageTk.PhotoImage(img)
lbl_img.config(image=photo_ref[0])
lbl_page.config(text=f"Strana {n + 1} / {page_count}")
btn_prev.config(state="normal" if n > 0 else "disabled")
btn_next.config(state="normal" if n < page_count - 1 else "disabled")
btn_prev = tk.Button(frame_nav, text="◄ Předchozí", command=lambda: show(current[0] - 1))
btn_prev.pack(side="left")
btn_next = tk.Button(frame_nav, text="Další ►", command=lambda: show(current[0] + 1))
btn_next.pack(side="left")
show(0)
root.update_idletasks()
try:
import sys as _sys
_sys.path.insert(0, str(Path(__file__).parent))
from window_layout import get_layout, apply_geometry
_layout = get_layout()
def _fallback_prev():
sw = root.winfo_screenwidth()
w = root.winfo_width()
x = (sw - w) // 2
root.geometry(f"+{x}+0")
apply_geometry(root, _layout, "preview_viewer", fallback_fn=_fallback_prev)
except Exception:
sw = root.winfo_screenwidth()
w = root.winfo_width()
x = (sw - w) // 2
root.geometry(f"+{x}+0")
# Zapiš geometrii do souboru pokud byl předán argument --write-geometry=<cesta>
import json as _json
for arg in sys.argv:
if arg.startswith("--write-geometry="):
geom_path = Path(arg.split("=", 1)[1])
root.update_idletasks()
_x = root.winfo_x()
_y = root.winfo_y()
_w = root.winfo_width()
_h = root.winfo_height()
geom_path.write_text(_json.dumps({"x": _x, "y": _y, "w": _w, "h": _h}), encoding="utf-8")
break
root.lift()
root.attributes("-topmost", True)
root.after(1500, lambda: root.attributes("-topmost", False))
root.mainloop()
if __name__ == "__main__":
main()
@@ -0,0 +1,116 @@
"""
Standalone dialog pro schválení / opravu názvu souboru.
Spouští se jako subprocess z extract_patient_info.py.
Argumenty: rename_dialog.py <json_soubor>
JSON vstup: { "nazev": "...", "info_lines": [...] }
JSON výstup: { "value": "..." } nebo { "value": null }
"""
import json
import os
import sys
from pathlib import Path
import tkinter as tk
if sys.platform == "win32":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
def main():
if len(sys.argv) < 2:
print(json.dumps({"value": None}))
sys.exit(0)
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
nazev = data.get("nazev") or ""
info_lines = data.get("info_lines") or []
result = {"value": None}
root = tk.Tk()
root.title("Schválení názvu souboru")
root.resizable(True, False)
root.attributes("-topmost", True)
pad = {"padx": 12, "pady": 6}
frame_info = tk.Frame(root, bg="#f0f0f0", bd=1, relief="sunken")
frame_info.pack(fill="x", **pad)
for line in info_lines:
color = "#b00000" if line.startswith("") else "#004080" if line.startswith("") else "#333"
tk.Label(frame_info, text=line, anchor="w", bg="#f0f0f0",
fg=color, font=("Segoe UI", 10)).pack(fill="x", padx=8, pady=1)
tk.Label(root, text="Název souboru (bez .pdf):", anchor="w",
font=("Segoe UI", 9, "bold")).pack(fill="x", padx=12, pady=(10, 2))
nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev
var = tk.StringVar(value=nazev_bez)
entry = tk.Entry(root, textvariable=var, font=("Segoe UI", 10), width=135)
entry.pack(fill="x", padx=12, pady=(0, 10))
entry.icursor(tk.END)
entry.focus_set()
frame_btn = tk.Frame(root)
frame_btn.pack(pady=(0, 12))
def schvalit(event=None):
result["value"] = var.get().strip()
root.destroy()
def preskocit(event=None):
result["value"] = None
root.destroy()
tk.Button(frame_btn, text="✓ Schválit (Enter)", command=schvalit,
bg="#2a7a2a", fg="white", font=("Segoe UI", 10, "bold"),
padx=16, pady=6).pack(side="left", padx=8)
tk.Button(frame_btn, text="✗ Přeskočit (Esc)", command=preskocit,
bg="#7a2a2a", fg="white", font=("Segoe UI", 10),
padx=16, pady=6).pack(side="left", padx=8)
root.bind("<Return>", schvalit)
root.bind("<Escape>", preskocit)
root.update_idletasks()
sw = root.winfo_screenwidth()
w = root.winfo_width()
h = root.winfo_height()
x = (sw - w) // 2
# Skutečná použitelná výška (bez taskbaru)
import ctypes, ctypes.wintypes
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0)
work_bottom = rect.bottom # spodní hrana work area
# Pozice pod preview oknem pokud byl předán argument --below-y=N
below_y = None
for arg in sys.argv:
if arg.startswith("--below-y="):
below_y = int(arg.split("=", 1)[1])
break
if below_y is not None and below_y + h + 10 <= work_bottom:
y = below_y # vejde se pod preview
else:
y = max(0, work_bottom - h - 10) # přilepit těsně nad taskbar
root.geometry(f"+{x}+{y}")
root.lift()
root.focus_force()
root.after(100, lambda: root.focus_force())
root.after(200, lambda: root.attributes("-topmost", True))
root.mainloop()
print(json.dumps({"value": result["value"]}, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,221 @@
"""
Standalone dialog pro schválení / opravu názvu souboru.
Spouští se jako subprocess z extract_patient_info_novy_test.py.
Argumenty: rename_dialog_test.py <json_soubor>
JSON vstup: {
"nazev": "...",
"info_lines": [...],
"duplicity": [...], # seznam názvů existujících souborů pro stejné RC+datum
"varianty": [...] # seznam návrhů názvu od Claude (unikátní, seřazené od nejlepší)
}
JSON výstup: { "value": "..." } nebo { "value": null }
"""
import json
import os
import sys
from pathlib import Path
import tkinter as tk
from tkinter import font as tkfont
if sys.platform == "win32":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
def main():
if len(sys.argv) < 2:
print(json.dumps({"value": None}))
sys.exit(0)
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
nazev = data.get("nazev") or ""
info_lines = data.get("info_lines") or []
duplicity = data.get("duplicity") or []
varianty = data.get("varianty") or []
result = {"value": None}
root = tk.Tk()
root.title("Schválení názvu souboru")
root.resizable(True, False)
root.attributes("-topmost", True)
pad = {"padx": 12, "pady": 4}
font_ui = ("Segoe UI", 10)
font_bold = ("Segoe UI", 9, "bold")
font_small = ("Segoe UI", 9)
# ── 1. Info panel (Medicus status) ────────────────────────────────────────
frame_info = tk.Frame(root, bg="#f0f0f0", bd=1, relief="sunken")
frame_info.pack(fill="x", **pad)
for line in info_lines:
color = "#b00000" if line.startswith("") or line.startswith("") else \
"#2a7a2a" if line.startswith("") else "#333"
tk.Label(frame_info, text=line, anchor="w", bg="#f0f0f0",
fg=color, font=font_ui).pack(fill="x", padx=8, pady=1)
# ── 2. Listbox 1 — duplicity ──────────────────────────────────────────────
if duplicity:
tk.Label(root, text="⚠ Již existující dokumenty pro toto datum:",
anchor="w", fg="#b00000", font=font_bold).pack(fill="x", padx=12, pady=(8, 2))
frame_dup = tk.Frame(root)
frame_dup.pack(fill="x", padx=12, pady=(0, 4))
sb_dup = tk.Scrollbar(frame_dup, orient="vertical")
lb_duplicity = tk.Listbox(
frame_dup,
yscrollcommand=sb_dup.set,
font=font_small,
height=min(len(duplicity), 4),
selectmode="single",
activestyle="dotbox",
bg="#fff8f0",
selectbackground="#e0b000",
selectforeground="#000",
)
sb_dup.config(command=lb_duplicity.yview)
sb_dup.pack(side="right", fill="y")
lb_duplicity.pack(side="left", fill="x", expand=True)
for d in duplicity:
lb_duplicity.insert(tk.END, d)
# OnClick — připraveno pro budoucí funkcionalitu
def on_duplicita_click(event):
pass # TODO: budoucí akce při výběru duplicity
lb_duplicity.bind("<<ListboxSelect>>", on_duplicita_click)
# ── 3. Listbox 2 — návrhy Claudea ─────────────────────────────────────────
nazev_bez = nazev[:-4] if nazev.endswith(".pdf") else nazev
var = tk.StringVar(value=nazev_bez)
if varianty:
tk.Label(root, text="Návrhy pojmenování (kliknutím vyberte):",
anchor="w", font=font_bold).pack(fill="x", padx=12, pady=(8, 2))
frame_var = tk.Frame(root)
frame_var.pack(fill="x", padx=12, pady=(0, 4))
sb_var = tk.Scrollbar(frame_var, orient="vertical")
lb_varianty = tk.Listbox(
frame_var,
yscrollcommand=sb_var.set,
font=font_small,
height=min(len(varianty), 6),
selectmode="single",
activestyle="dotbox",
bg="#f0f8ff",
selectbackground="#2a7a2a",
selectforeground="#fff",
)
sb_var.config(command=lb_varianty.yview)
sb_var.pack(side="right", fill="y")
lb_varianty.pack(side="left", fill="x", expand=True)
for v in varianty:
v_bez = v[:-4] if v.endswith(".pdf") else v
lb_varianty.insert(tk.END, v_bez)
# Klik → přepsat Entry
def on_varianta_click(event):
sel = lb_varianty.curselection()
if sel:
var.set(lb_varianty.get(sel[0]))
lb_varianty.bind("<<ListboxSelect>>", on_varianta_click)
# ── 4. Entry — definitivní název ──────────────────────────────────────────
tk.Label(root, text="Název souboru (bez .pdf):", anchor="w",
font=font_bold).pack(fill="x", padx=12, pady=(10, 2))
entry = tk.Entry(root, textvariable=var, font=font_ui, width=135)
entry.pack(fill="x", padx=12, pady=(0, 10))
entry.icursor(tk.END)
entry.focus_set()
# ── 5. Tlačítka ───────────────────────────────────────────────────────────
frame_btn = tk.Frame(root)
frame_btn.pack(pady=(0, 12))
def schvalit(event=None):
result["value"] = var.get().strip()
root.destroy()
def preskocit(event=None):
result["value"] = None
root.destroy()
tk.Button(frame_btn, text="✓ Schválit (Enter)", command=schvalit,
bg="#2a7a2a", fg="white", font=("Segoe UI", 10, "bold"),
padx=16, pady=6).pack(side="left", padx=8)
tk.Button(frame_btn, text="✗ Přeskočit (Esc)", command=preskocit,
bg="#7a2a2a", fg="white", font=font_ui,
padx=16, pady=6).pack(side="left", padx=8)
root.bind("<Return>", schvalit)
root.bind("<Escape>", preskocit)
# ── Pozicování okna ───────────────────────────────────────────────────────
root.update_idletasks()
sw = root.winfo_screenwidth()
w = root.winfo_width()
h = root.winfo_height()
x = (sw - w) // 2
try:
sys.path.insert(0, str(Path(__file__).parent))
from window_layout import get_layout, apply_geometry
_layout = get_layout()
def _fallback_dlg():
import ctypes, ctypes.wintypes
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0)
work_bottom = rect.bottom
below_y = None
for arg in sys.argv:
if arg.startswith("--below-y="):
below_y = int(arg.split("=", 1)[1])
break
if below_y is not None and below_y + h + 10 <= work_bottom:
y = below_y
else:
y = max(0, work_bottom - h - 10)
root.geometry(f"+{x}+{y}")
apply_geometry(root, _layout, "rename_dialog", fallback_fn=_fallback_dlg)
except Exception:
import ctypes, ctypes.wintypes
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0)
work_bottom = rect.bottom
below_y = None
for arg in sys.argv:
if arg.startswith("--below-y="):
below_y = int(arg.split("=", 1)[1])
break
if below_y is not None and below_y + h + 10 <= work_bottom:
y = below_y
else:
y = max(0, work_bottom - h - 10)
root.geometry(f"+{x}+{y}")
root.lift()
root.focus_force()
root.after(100, lambda: root.focus_force())
root.after(200, lambda: root.attributes("-topmost", True))
root.mainloop()
print(json.dumps({"value": result["value"]}, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,50 @@
"""
Testovací skript pro ladění rename_dialog.py — spusť přímo, okno se otevře
a můžeš ověřit kódování češtiny (ž, š, č, ř, á, é, í, ó, ú, ů, ď, ť, ň).
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
RENAME_DIALOG = Path(__file__).parent / "rename_dialog.py"
TEST_NAZEV = "7209160057 2026-04-20 Procházka, David [žádost o předání zdravotních informací] [pro nového PL MUDr. Drahomíra Krivosudskář]"
TEST_INFO_LINES = [
"✓ Medicus: Procházka David | RČ 720916/0057",
"⚠ Zkouška češtiny: ž š č ř á é í ó ú ů ď ť ň",
"— další řádek s háčky a čárkami: přiřadit, výříznout, Krivosudskář",
]
def main():
tmp = Path(tempfile.mktemp(suffix=".json"))
tmp.write_text(
json.dumps({"nazev": TEST_NAZEV, "info_lines": TEST_INFO_LINES}, ensure_ascii=False),
encoding="utf-8",
)
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
proc = subprocess.run(
[sys.executable, str(RENAME_DIALOG), str(tmp)],
capture_output=True, text=True, encoding="utf-8", env=env,
)
tmp.unlink(missing_ok=True)
print("=== stdout ===")
print(proc.stdout)
if proc.stderr.strip():
print("=== stderr ===")
print(proc.stderr)
out = proc.stdout.strip()
if out:
val = json.loads(out).get("value")
print(f"\nVrácená hodnota: {val!r}")
else:
print("\nDialog zavřen bez potvrzení.")
if __name__ == "__main__":
main()
@@ -0,0 +1,157 @@
"""
Jedno okno pro výběr kompresní varianty PDF.
Nahoře tlačítka 1N pro přepínání, tlačítko "Tohle beru" pro potvrzení.
Argumenty: variant_picker.py <json_soubor>
JSON vstup: [{"path": "...", "label": "150 DPI / q80", "size_kb": 139}, ...]
JSON výstup (stdout): {"chosen": "cesta/k/souboru"}
"""
import json
import sys
from pathlib import Path
import tkinter as tk
from PIL import Image, ImageTk
import fitz
def show(variants: list) -> str | None:
"""Zobrazí picker přímo (bez subprocesů). Vrátí cestu k vybrané variantě nebo None."""
chosen = {"path": None}
docs = [fitz.open(v["path"]) for v in variants]
current = [0]
photo_ref = [None]
root = tk.Tk()
root.tk.call("encoding", "system", "utf-8")
root.attributes("-topmost", True)
sh = root.winfo_screenheight()
sw = root.winfo_screenwidth()
win_h = sh - 80 # odečteme taskbar + title bar
img_h = win_h - 160
img_w = sw // 2 # šířka okna = polovina monitoru
x = (sw - img_w) // 2
root.geometry(f"{img_w}x{win_h}+{x}+0")
root.resizable(False, False)
# ── Horní panel s tlačítky variant ──
frame_top = tk.Frame(root, bg="#222")
frame_top.pack(fill="x")
btn_variants = []
current_page = [0]
def show(n, page_n=0):
current[0] = n
current_page[0] = page_n
doc = docs[n]
page = doc[page_n]
zoom = min(img_w / page.rect.width, img_h / page.rect.height)
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
photo_ref[0] = ImageTk.PhotoImage(img)
lbl_img.config(image=photo_ref[0])
page_count = len(doc)
root.title(f"Varianta {n+1}: {variants[n]['label']} ({variants[n]['size_kb']} kB) — strana {page_n+1}/{page_count}")
for i, b in enumerate(btn_variants):
b.config(bg="#2a5a9a" if i == n else "#444")
btn_prev_page.config(state="normal" if page_n > 0 else "disabled")
btn_next_page.config(state="normal" if page_n < page_count - 1 else "disabled")
for i, v in enumerate(variants):
b = tk.Button(
frame_top,
text=f"{i+1}. {v['label']}\n{v['size_kb']} kB",
font=("Segoe UI", 9, "bold"),
bg="#444", fg="white",
relief="flat", padx=8, pady=6,
command=lambda n=i: show(n),
)
b.pack(side="left", padx=2, pady=4)
btn_variants.append(b)
# ── Tlačítka Beru / Přeskočit — stejný styl jako varianty ──
def beru():
chosen["path"] = variants[current[0]]["path"]
root.destroy()
def preskocit():
root.destroy()
tk.Button(
frame_top,
text="✓ Tohle beru\n",
command=beru,
bg="#2a7a2a", fg="white",
font=("Segoe UI", 9, "bold"),
relief="flat", padx=8, pady=6,
).pack(side="left", padx=2, pady=4)
tk.Button(
frame_top,
text="✗ Přeskočit\n",
command=preskocit,
bg="#7a2a2a", fg="white",
font=("Segoe UI", 9, "bold"),
relief="flat", padx=8, pady=6,
).pack(side="left", padx=2, pady=4)
# ── Navigace stran — úplně vpravo ──
btn_next_page = tk.Button(
frame_top,
text="Další ►\n",
command=lambda: show(current[0], current_page[0] + 1),
bg="#555", fg="white",
font=("Segoe UI", 9, "bold"),
relief="flat", padx=8, pady=6,
)
btn_next_page.pack(side="right", padx=2, pady=4)
btn_prev_page = tk.Button(
frame_top,
text="◄ Před.\n",
command=lambda: show(current[0], current_page[0] - 1),
bg="#555", fg="white",
font=("Segoe UI", 9, "bold"),
relief="flat", padx=8, pady=6,
)
btn_prev_page.pack(side="right", padx=2, pady=4)
# ── Obrázek ──
lbl_img = tk.Label(root, bg="black")
lbl_img.pack(fill="both", expand=True)
root.bind("<Key-1>", lambda e: show(0))
root.bind("<Key-2>", lambda e: show(1))
root.bind("<Key-3>", lambda e: show(2))
root.bind("<Key-4>", lambda e: show(3))
root.bind("<Key-5>", lambda e: show(4))
root.bind("<Return>", lambda e: beru())
root.bind("<Escape>", lambda e: preskocit())
show(0)
root.lift()
root.focus_force()
root.after(100, lambda: root.focus_force())
root.after(200, lambda: root.attributes("-topmost", True))
root.mainloop()
for d in docs:
try:
d.close()
except Exception:
pass
return chosen["path"]
def main():
if len(sys.argv) < 2:
sys.exit(1)
variants = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
result = show(variants)
print(json.dumps({"chosen": result}, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,99 @@
"""
Pomocný modul pro pozicování oken podle hostname počítače.
Nastavení se načítá z layout_settings.json ve stejném adresáři.
Použití:
from window_layout import get_layout
layout = get_layout()
geom = layout["preview_viewer"] # {"x": 1100, "y": 0, "w": 1400, "h": 2100}
root.geometry(f"{geom['w']}x{geom['h']}+{geom['x']}+{geom['y']}")
Pro okna s "anchor": "bottom" (rename_dialog) použij get_bottom_y():
y = layout["rename_dialog"]["x"] # jen x, y se počítá dynamicky
"""
import json
import socket
from pathlib import Path
_SETTINGS_FILE = Path(__file__).parent / "layout_settings.json"
# Výchozí fallback layout (jeden monitor, původní chování)
_DEFAULT = {
"duplicity_viewer": None, # None = nepoužívat pevnou geometrii
"preview_viewer": None,
"rename_dialog": None,
}
def get_hostname() -> str:
return socket.gethostname().upper()
def get_layout() -> dict:
"""
Vrátí slovník s geometrií oken pro aktuální počítač.
Pokud hostname není v settings, vrátí fallback (None = původní chování).
"""
hostname = get_hostname()
if not _SETTINGS_FILE.exists():
return dict(_DEFAULT)
try:
settings = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return dict(_DEFAULT)
return settings.get(hostname, dict(_DEFAULT))
def apply_geometry(root, layout: dict, window: str, fallback_fn=None):
"""
Aplikuje geometrii okna z layoutu.
- root: tkinter Tk nebo Toplevel
- layout: výsledek get_layout()
- window: klíč ("duplicity_viewer", "preview_viewer", "rename_dialog")
- fallback_fn: callable() volaný pokud pro toto okno není layout definován
"""
geom = layout.get(window)
if not geom:
if fallback_fn:
fallback_fn()
return
w = geom.get("w")
h = geom.get("h")
x = geom.get("x", 0)
y = geom.get("y", 0)
if geom.get("anchor") == "bottom":
# Výška se ignoruje, okno se přilepí ke spodnímu okraji work area
import ctypes, ctypes.wintypes
rect = ctypes.wintypes.RECT()
ctypes.windll.user32.SystemParametersInfoW(48, 0, ctypes.byref(rect), 0)
root.update_idletasks()
h_actual = root.winfo_height()
y = rect.bottom - h_actual - 10
if w:
root.geometry(f"+{x}+{y}")
else:
root.geometry(f"+{x}+{y}")
else:
if w and h:
root.geometry(f"{w}x{h}+{x}+{y}")
elif w:
root.update_idletasks()
root.geometry(f"{w}x{root.winfo_height()}+{x}+{y}")
else:
root.update_idletasks()
root.geometry(f"+{x}+{y}")
if __name__ == "__main__":
hostname = get_hostname()
layout = get_layout()
print(f"Hostname: {hostname}")
if all(v is None for v in layout.values()):
print("Žádné nastavení pro tento počítač — používá se výchozí chování.")
print(f"Přidej klíč '{hostname}' do {_SETTINGS_FILE}")
else:
print(f"Layout pro '{hostname}':")
for k, v in layout.items():
print(f" {k}: {v}")