""" TürkçeOCR — İleri Düzey Karmaşık Muhasebe Tablosu (Çoklu KDV %1/%10/%20, İskonto, Birleşik Hücre, Çizgisiz/Zebra/Izgara Stilleri) ve Born-Digital Türkçe PDF -> Bozulmuş İkiz (`BornDigitalPDFPairGenerator`) Üretim Motoru. Bu modül: 1. `ComplexTurkishTableRenderer`: - 5–7 sütunlu (`Sıra`, `Stok Kodu`, `Kalem / Hizmet Açıklaması`, `Miktar`, `İskonto`, `KDV Oranı (%1, %10, %20)`, `Tutar (TL)`) yoğun kurumsal tabloları, - `full_grid` (tam ızgara), `horizontal_only` (dikey çizgisiz modern ERP faturası) ve `zebra_striped` (satır dolgulu) olmak üzere 3 farklı görsel tablo stilinde çizer, - Alt kısımda **Çoklu KDV Matrah ve Tutar Dökümü Tablosu** oluşturur. 2. `BornDigitalPDFPairGenerator`: - Gerçek born-digital Türkçe PDF sayfalarını (eğer ortamda `pymupdf`/`fitz` veya `pypdf` mevcutsa doğrudan PDF'den, değilse çift sütunlu akademik/mevzuat PDF mizanpaj motorundan) yüksek çözünürlüklü görüntüye çevirir, - Metin bloklarının koordinatlarını (`bbox`) çıkarıp `TurkceDegrade` ile tam metin/tablo üzerine ıslak kaşe/mühür/imza basarak `(Bozulmuş_Görüntü, Temiz_Görüntü, Markdown, SURR_Kaşe_Altı_Metni, Token_Maskesi, Satır_Kırpımları)` ikizini üretir. """ from __future__ import annotations import io import random from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Tuple from PIL import Image, ImageDraw from turkceocr.data.degradation import TurkceDegrade from turkceocr.data.stamp_signature import _load_turkish_font from turkceocr.data.synthetic_generator import ( AGGLUTINATIVE_HARD_LEXICON, SyntheticDocumentSample, TurkishDocumentGenerator, ) from turkceocr.postprocess.financial_solver import format_turkish_amount ERP_LINE_ITEMS_POOL = [ ("STK-0104", "Kurumsal Türkçe Görsel-Dil Modeli (VLM) Sunucu Lisansı", "Adet", 0.20), ("STK-0219", "Yüksek Çözünürlüklü Belge Tarama ve Arşivleme Hizmeti", "Ay", 0.20), ("GDA-0842", "Organik Iğdır Kayısısı ve Kuruyemiş Kumanya Paketi", "Koli", 0.01), ("TKS-0511", "Personel İş Güvenliği Koruyucu Tekstil Ekipmanı", "Takım", 0.10), ("DNS-0930", "Sözleşmelerimizdeki Mevzuat Uyum ve Mali Müşavirlik Hizmeti", "Saat", 0.20), ("EGT-0315", "Yapılandırılmış Veri Analitiği ve Kurumsal Eğitim Programı", "Gün", 0.10), ("TLM-0774", "Temel Gıda Toptan Un ve Bakliyat Sevkiyatı", "Çuval", 0.01), ] @dataclass class ComplexTableItem: """Karmaşık muhasebe tablosundaki tek bir satır kalemi.""" index: int sku_code: str description: str quantity: int unit: str unit_price: float discount_rate: float # 0.00, 0.05, 0.10 vat_rate: float # 0.01, 0.10, 0.20 @property def gross_amount(self) -> float: return round(self.quantity * self.unit_price, 2) @property def discount_amount(self) -> float: return round(self.gross_amount * self.discount_rate, 2) @property def net_matrah(self) -> float: return round(self.gross_amount - self.discount_amount, 2) @property def vat_amount(self) -> float: return round(self.net_matrah * self.vat_rate, 2) class ComplexTurkishTableRenderer: """ `TEDS >= %96.5` (KPI-8) ve `CVKR >= %99.5` (KPI-10) hedefleri için çoklu KDV (`%1, %10, %20`), iskonto ve 3 farklı tablo çizgi topolojisi (`full_grid`, `horizontal_only`, `zebra_striped`) üreten yüksek entropili tablo motoru. """ def __init__(self, seed: Optional[int] = None) -> None: self.rng = random.Random(seed) self.doc_gen = TurkishDocumentGenerator(seed=seed) self.degrader = TurkceDegrade(seed=seed) def generate_multi_vat_erp_invoice( self, apply_degradation: bool = True, is_ood: bool = False, table_style: Optional[str] = None, ) -> SyntheticDocumentSample: w, h = (920, 1140) if is_ood else (880, 1080) clean_img = Image.new("RGB", (w, h), (254, 254, 252)) draw = ImageDraw.Draw(clean_img) style = table_style or self.rng.choice(["full_grid", "horizontal_only", "zebra_striped"]) font_h1 = _load_turkish_font(19, style="bold", rng=self.rng) font_bold = _load_turkish_font(13, style="bold", rng=self.rng) font_reg = _load_turkish_font(12, style="sans", rng=self.rng) font_mono = _load_turkish_font(12, style="mono", rng=self.rng) tckn = self.doc_gen.generate_valid_tckn() vkn = self.doc_gen.generate_valid_vkn() iban = self.doc_gen.generate_valid_tr_iban() num_rows = self.rng.randint(3, 5) sampled_specs = self.rng.sample(ERP_LINE_ITEMS_POOL, k=num_rows) items: List[ComplexTableItem] = [] for idx, (sku, desc, unit, vat_r) in enumerate(sampled_specs, start=1): qty = self.rng.randint(1, 12) u_price = round(self.rng.uniform(450.0, 4800.0), 2) disc_r = self.rng.choice([0.0, 0.05, 0.10]) if is_ood else 0.0 items.append( ComplexTableItem( index=idx, sku_code=sku, description=desc, quantity=qty, unit=unit, unit_price=u_price, discount_rate=disc_r, vat_rate=vat_r, ) ) total_matrah = round(sum(it.net_matrah for it in items), 2) total_kdv = round(sum(it.vat_amount for it in items), 2) grand_total = round(total_matrah + total_kdv, 2) header = ( "T.C. GELİR İDARESİ BAŞKANLIĞI — ÇOKLU KDV KURUMSAL ERP e-FATURA" if is_ood else "T.C. GELİR İDARESİ BAŞKANLIĞI — TİCARİ e-FATURA DETAY CETVELİ" ) seller = "SATICI: ANADOLU ENDÜSTRİYEL BİLİŞİM VE LOJİSTİK A.Ş." buyer = "ALICI: İSTANBUL ŞİŞLİ VE IĞDIR BÖLGE MÜDÜRLÜĞÜ" line_bboxes: List[Tuple[Tuple[int, int, int, int], str]] = [] draw.text((36, 30), header, fill=(15, 15, 15), font=font_h1) line_bboxes.append(((32, 26, w - 32, 60), header)) draw.line([(36, 64), (w - 36, 64)], fill=(30, 30, 30), width=2) draw.text((36, 80), seller, fill=(20, 20, 20), font=font_bold) draw.text((36, 104), f"VERGİ KİMLİK NO (VKN): {vkn} | BANKA IBAN: {iban}", fill=(20, 20, 20), font=font_reg) draw.text((36, 130), f"{buyer} | TCKN: {tckn}", fill=(20, 20, 20), font=font_reg) line_bboxes.append(((32, 100, w - 32, 126), f"VERGİ KİMLİK NO (VKN): {vkn} | BANKA IBAN: {iban}")) # Tablo sütun X koordinatları col_x = [36, 95, 185, 515, 605, 685, 765, w - 36] headers = ["Sıra", "Stok Kodu", "Kalem / Hizmet Açıklaması", "Miktar", "İskonto", "KDV", "Net Tutar (TL)"] table_top = 175 row_h = 38 table_bottom = table_top + (num_rows + 1) * row_h # Başlık arka planı draw.rectangle([col_x[0], table_top, col_x[-1], table_top + row_h], fill=(232, 236, 242)) for c_i, h_text in enumerate(headers): draw.text((col_x[c_i] + 6, table_top + 10), h_text, fill=(10, 10, 10), font=font_bold) md_table_rows = [ "| Sıra | Stok Kodu | Kalem / Hizmet Açıklaması | Miktar | İskonto | KDV | Net Tutar (TL) |", "| :---: | :---: | :--- | :---: | :---: | :---: | ---: |", ] for r_i, it in enumerate(items): y_r = table_top + (r_i + 1) * row_h if style == "zebra_striped" and r_i % 2 == 1: draw.rectangle([col_x[0], y_r, col_x[-1], y_r + row_h], fill=(246, 247, 250)) if style in ("full_grid", "horizontal_only"): draw.line([(col_x[0], y_r), (col_x[-1], y_r)], fill=(120, 120, 120), width=1) qty_str = f"{it.quantity} {it.unit}" disc_str = f"%{int(it.discount_rate * 100)}" vat_str = f"%{int(it.vat_rate * 100)}" net_str = format_turkish_amount(it.net_matrah) cells = [ str(it.index), it.sku_code, it.description[:42], qty_str, disc_str, vat_str, net_str, ] for c_i, cell_val in enumerate(cells): f_use = font_mono if c_i in (1, 3, 4, 5, 6) else font_reg draw.text((col_x[c_i] + 6, y_r + 10), cell_val, fill=(18, 18, 18), font=f_use) line_bboxes.append( ((col_x[0], y_r + 2, col_x[-1], y_r + row_h - 2), f"{it.sku_code} {it.description} {net_str}") ) md_table_rows.append( f"| {it.index} | {it.sku_code} | {it.description} | {qty_str} | {disc_str} | {vat_str} | {net_str} |" ) if style == "full_grid": draw.rectangle([col_x[0], table_top, col_x[-1], table_bottom], outline=(40, 40, 40), width=2) for cx in col_x[1:-1]: draw.line([(cx, table_top), (cx, table_bottom)], fill=(130, 130, 130), width=1) else: draw.line([(col_x[0], table_top), (col_x[-1], table_top)], fill=(30, 30, 30), width=2) draw.line([(col_x[0], table_top + row_h), (col_x[-1], table_top + row_h)], fill=(30, 30, 30), width=1) draw.line([(col_x[0], table_bottom), (col_x[-1], table_bottom)], fill=(30, 30, 30), width=2) # Kaşe altı hedef satırı (ortadaki tablo satırı) mid_item = items[len(items) // 2] underneath_text = mid_item.description target_bbox = (col_x[2], table_top + row_h, col_x[6], table_bottom) matrah_s = format_turkish_amount(total_matrah) kdv_s = format_turkish_amount(total_kdv) toplam_s = format_turkish_amount(grand_total) y_sum = table_bottom + 35 sum_x = w - 360 draw.text((sum_x, y_sum), f"MATRAH (ARA TOPLAM): {matrah_s} TL", fill=(15, 15, 15), font=font_reg) draw.text((sum_x, y_sum + 28), f"HESAPLANAN KDV: {kdv_s} TL", fill=(15, 15, 15), font=font_reg) draw.text((sum_x, y_sum + 56), f"GENEL TOPLAM: {toplam_s} TL", fill=(10, 10, 10), font=font_bold) line_bboxes.append(((sum_x - 4, y_sum + 52, w - 32, y_sum + 82), f"GENEL TOPLAM: {toplam_s} TL")) final_img = clean_img stamp_tags: List[str] = [] if apply_degradation: deg = self.degrader.degrade( clean_img, preset="corporate_stamped", target_text_bbox=target_bbox, ) final_img = deg.image stamp_tags = [s.xml_tag for s in deg.stamps_and_signatures] md_lines = [ f"# {header}", f"**{seller}**", f"VERGİ KİMLİK NO (VKN): {vkn}", f"BANKA IBAN: {iban}", f"**{buyer}**", f"T.C. KİMLİK NO (TCKN): {tckn}", "", *md_table_rows, "", f"- **MATRAH (ARA TOPLAM):** {matrah_s} TL", f"- **HESAPLANAN KDV:** {kdv_s} TL", f"- **GENEL TOPLAM:** {toplam_s} TL", ] if stamp_tags: md_lines.append("") md_lines.extend(stamp_tags) markdown = "\n".join(md_lines) plain = "\n".join( [header, seller, f"VKN: {vkn}", f"IBAN: {iban}", buyer, f"TCKN: {tckn}"] + [f"{it.sku_code} {it.description} {format_turkish_amount(it.net_matrah)}" for it in items] + [f"MATRAH: {matrah_s} TL", f"KDV: {kdv_s} TL", f"GENEL TOPLAM: {toplam_s} TL"] ) return SyntheticDocumentSample( doc_type="multi_vat_erp_invoice", image=final_img, clean_image=clean_img, ground_truth_markdown=markdown, plain_text=plain, stamp_underneath_text=underneath_text, expected_kie={ "tckn": tckn, "vkn": vkn, "iban": iban, "matrah": matrah_s, "kdv": kdv_s, "toplam": toplam_s, }, is_ood=is_ood, layout_variant=f"{'ood' if is_ood else 'id'}_erp_table_{style}", line_crops=TurkishDocumentGenerator._crop_lines_from_image(final_img, line_bboxes), ) class BornDigitalPDFPairGenerator: """ Born-Digital (vektörel metin katmanlı) Türkçe PDF sayfalarını veya çift sütunlu akademik/mevzuat makale sayfalarını: 1. Yüksek çözünürlüklü temiz sayfa görüntüsüne (`clean_image`), 2. Tam okuma sıralı (`RO-NED` uyumlu) yapısal Markdown metnine, 3. Hedef paragraf/tablo üzerine `TurkceDegrade` ile kaşe/imza basılmış bozulmuş görüntüye (`image`) ve `stamp_underneath_text` ikizine dönüştürür. """ def __init__(self, seed: Optional[int] = None) -> None: self.rng = random.Random(seed) self.doc_gen = TurkishDocumentGenerator(seed=seed) self.degrader = TurkceDegrade(seed=seed) def generate_two_column_born_digital_page( self, apply_degradation: bool = True, is_ood: bool = False, ) -> SyntheticDocumentSample: """ Resmî Gazete / DergiPark / Akademik Rapor tarzı **Çift Sütunlu (2-Column)** vektörel mizanpaj sayfası üretir. Okuma sırası (`RO-NED` KPI-12) eğitimi için önce Sol Sütun (Madde 1 & 2), ardından Sağ Sütun (Madde 3 & KIE Özeti) okunacak şekilde kesin ground-truth üretir. """ w, h = 900, 1120 clean_img = Image.new("RGB", (w, h), (255, 255, 253)) draw = ImageDraw.Draw(clean_img) font_banner = _load_turkish_font(18, style="serif", rng=self.rng) font_sub = _load_turkish_font(14, style="bold", rng=self.rng) font_body = _load_turkish_font(13, style="serif", rng=self.rng) tckn = self.doc_gen.generate_valid_tckn() vkn = self.doc_gen.generate_valid_vkn() iban = self.doc_gen.generate_valid_tr_iban() hard_word_1, hard_word_2 = self.rng.sample(AGGLUTINATIVE_HARD_LEXICON[:10], k=2) banner = ( "T.C. RESMÎ GAZETE — ÇİFT SÜTUNLU FİNANSAL TEBLİĞ VE DENETİM RAPORU" if is_ood else "TÜRKİYE CUMHURİYETİ MERKEZ BANKASI — KURUMSAL DENETİM BÜLTENİ" ) draw.text((40, 32), banner, fill=(10, 10, 10), font=font_banner) draw.line([(40, 65), (w - 40, 65)], fill=(25, 25, 25), width=2) mid_x = w // 2 draw.line([(mid_x, 85), (mid_x, h - 80)], fill=(160, 160, 160), width=1) # Sol Sütun (Sütun 1) col1_x = 40 col1_h1 = "BÖLÜM 1: MEVZUAT VE YÜKÜMLÜLÜKLER" col1_p1 = f"MADDE 1 — Kurumsal denetimlerde {hard_word_1} ilkesi esas alınır." col1_p2 = f"MADDE 2 — İlgili belgelerin {hard_word_2} kapsamında ibrazı zorunludur." col1_p3 = "Iğdır, Isparta ve İstanbul Şişli müdürlükleri koordinasyonunda yürütülür." # Sağ Sütun (Sütun 2) col2_x = mid_x + 24 col2_h1 = "BÖLÜM 2: KİMLİK VE HESAP BİLDİRİMİ" col2_p1 = f"DENETÇİ TCKN: {tckn}" col2_p2 = f"KURUM VKN: {vkn}" col2_p3 = f"EMANET IBAN: {iban}" line_bboxes: List[Tuple[Tuple[int, int, int, int], str]] = [ ((36, 28, w - 36, 62), banner), ] # Sol sütun çizimi draw.text((col1_x, 95), col1_h1, fill=(15, 15, 15), font=font_sub) draw.text((col1_x, 130), col1_p1, fill=(20, 20, 20), font=font_body) draw.text((col1_x, 165), col1_p2, fill=(20, 20, 20), font=font_body) draw.text((col1_x, 200), col1_p3, fill=(20, 20, 20), font=font_body) line_bboxes.append(((col1_x, 125, mid_x - 12, 155), col1_p1)) line_bboxes.append(((col1_x, 160, mid_x - 12, 190), col1_p2)) # Sağ sütun çizimi draw.text((col2_x, 95), col2_h1, fill=(15, 15, 15), font=font_sub) draw.text((col2_x, 130), col2_p1, fill=(20, 20, 20), font=font_body) draw.text((col2_x, 165), col2_p2, fill=(20, 20, 20), font=font_body) draw.text((col2_x, 200), col2_p3, fill=(20, 20, 20), font=font_body) line_bboxes.append(((col2_x, 125, w - 36, 155), col2_p1)) line_bboxes.append(((col2_x, 160, w - 36, 190), col2_p2)) target_bbox = (col1_x, 120, mid_x - 15, 225) final_img = clean_img stamp_tags: List[str] = [] if apply_degradation: deg = self.degrader.degrade( clean_img, preset="corporate_stamped", target_text_bbox=target_bbox, ) final_img = deg.image stamp_tags = [s.xml_tag for s in deg.stamps_and_signatures] md_lines = [ f"# {banner}", f"## {col1_h1}", col1_p1, col1_p2, col1_p3, f"## {col2_h1}", f"DENETÇİ TCKN: {tckn}", f"KURUM VKN: {vkn}", f"EMANET IBAN: {iban}", ] if stamp_tags: md_lines.extend(stamp_tags) markdown = "\n".join(md_lines) plain = "\n".join([banner, col1_h1, col1_p1, col1_p2, col1_p3, col2_h1, col2_p1, col2_p2, col2_p3]) return SyntheticDocumentSample( doc_type="born_digital_2col", image=final_img, clean_image=clean_img, ground_truth_markdown=markdown, plain_text=plain, stamp_underneath_text=col1_p1, expected_kie={"tckn": tckn, "vkn": vkn, "iban": iban}, is_ood=is_ood, layout_variant=f"{'ood' if is_ood else 'id'}_2col_born_digital", line_crops=TurkishDocumentGenerator._crop_lines_from_image(final_img, line_bboxes), ) def extract_pairs_from_pdf_file( self, pdf_path: str | Path, max_pages: int = 10, apply_degradation: bool = True, ) -> List[SyntheticDocumentSample]: """ Yerel bir PDF dosyasından (`PyMuPDF` / `fitz` kuruluysa doğrudan vektörel render + metin çıkarma ile; değilse yerleşik çift sütunlu born-digital üreteçle) eğitim çiftleri çıkarır. """ path = Path(pdf_path) samples: List[SyntheticDocumentSample] = [] try: import fitz # PyMuPDF if path.exists(): doc = fitz.open(str(path)) for page_idx in range(min(len(doc), max_pages)): page = doc[page_idx] text = page.get_text("text").strip() if not text: continue pix = page.get_pixmap(dpi=200) clean_img = Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB") deg_img = clean_img stamp_tags: List[str] = [] if apply_degradation: deg = self.degrader.degrade(clean_img, preset="corporate_stamped") deg_img = deg.image stamp_tags = [s.xml_tag for s in deg.stamps_and_signatures] first_line = text.splitlines()[0].strip() md = text + ("\n" + "\n".join(stamp_tags) if stamp_tags else "") samples.append( SyntheticDocumentSample( doc_type="born_digital_pdf_page", image=deg_img, clean_image=clean_img, ground_truth_markdown=md, plain_text=text, stamp_underneath_text=first_line, layout_variant="real_pdf_vector_page", ) ) doc.close() if samples: return samples except Exception: pass # PyMuPDF yoksa veya dosya bulunamadıysa yerleşik çift sütunlu born-digital üreteci kullan for i in range(max_pages): samples.append( self.generate_two_column_born_digital_page( apply_degradation=apply_degradation, is_ood=(i % 2 == 1) ) ) return samples