"""Belgelerden tablo çıkarma: PDF (pdfplumber, Camelot), HTML, CSV/XLSX. Tüm çıkarıcılar ortak bir ara gösterime dönüşür: RawTable -> hücreler + satır/sütun birleşimleri (rowspan/colspan) resolve() -> çok satırlı başlıkları düzleştirir, birleşik hücreleri yayar ve ResolvedTable (başlık parçaları + gövde metinleri) üretir. """ from __future__ import annotations import re from dataclasses import dataclass, field from pathlib import Path from .trnum import clean_cell, fold, parse_date, parse_number @dataclass class Cell: row: int col: int text: str rowspan: int = 1 colspan: int = 1 @dataclass class RawTable: cells: list[Cell] n_rows: int n_cols: int caption: str = "" source: str = "" page: int | None = None extractor: str = "" context: str = "" # tablo öncesi metin (birim, açıklama ipuçları için) def grid(self, fill_spans: bool = True) -> tuple[list[list[str]], list[list[str]]]: """(metin ızgarası, birleşim işaret ızgarası). İşaretler: '' tek hücre, 'o' birleşimin kökü, 'h' yatay kopya, 'v' dikey kopya.""" g = [["" for _ in range(self.n_cols)] for _ in range(self.n_rows)] m = [["" for _ in range(self.n_cols)] for _ in range(self.n_rows)] for c in self.cells: for dr in range(c.rowspan): for dc in range(c.colspan): r, k = c.row + dr, c.col + dc if r >= self.n_rows or k >= self.n_cols: continue if dr == 0 and dc == 0: g[r][k] = c.text m[r][k] = "o" if (c.rowspan > 1 or c.colspan > 1) else "" else: g[r][k] = c.text if fill_spans else "" m[r][k] = "v" if dr > 0 else "h" return g, m @dataclass class ResolvedTable: caption: str header_parts: list[list[str]] # her sütun için başlık parçaları (üstten alta) body: list[list[str]] source: str = "" page: int | None = None extractor: str = "" n_header_rows: int = 1 merged_cells: int = 0 context: str = "" raw: RawTable | None = field(default=None, repr=False) @property def headers(self) -> list[str]: return [" ".join(p) for p in self.header_parts] # ======================================================================== PDF def _caption_from_text(text: str) -> str: lines = [l.strip() for l in (text or "").splitlines() if l.strip()] for l in reversed(lines[-4:]): if re.match(r"^(tablo|çizelge|cizelge|table)\s*[\d.:-]", fold(l)): return l # 'Tablo N' yoksa: '... Tablosu' / 'Bilanço' gibi başlık satırı; parantezli açıklama satırları başlık değildir for l in reversed(lines[-4:]): if not l.startswith("(") and re.search(r"\b(tablosu|cizelgesi|bilanco)\b", fold(l)): return l plain = [l for l in lines[-4:] if not l.startswith("(")] return plain[-1] if plain else (lines[-1] if lines else "") def _spans_from_bboxes(cell_boxes, tol: float = 1.5): """pdfplumber hücre kutularından satır/sütun sınırları ve birleşimleri hesaplar.""" def uniq(vals): out = [] for v in sorted(vals): if not out or abs(v - out[-1]) > tol: out.append(v) return out xs = uniq([b[0] for b in cell_boxes] + [b[2] for b in cell_boxes]) ys = uniq([b[1] for b in cell_boxes] + [b[3] for b in cell_boxes]) def idx(arr, v): return min(range(len(arr)), key=lambda i: abs(arr[i] - v)) out = [] for b in cell_boxes: c0, c1 = idx(xs, b[0]), idx(xs, b[2]) r0, r1 = idx(ys, b[1]), idx(ys, b[3]) out.append((r0, c0, max(1, r1 - r0), max(1, c1 - c0), b)) return out, len(ys) - 1, len(xs) - 1 def extract_pdf_pdfplumber(path: str | Path) -> list[RawTable]: import pdfplumber tables = [] with pdfplumber.open(str(path)) as pdf: for pno, page in enumerate(pdf.pages, start=1): for t in page.find_tables(): boxes = [c for c in t.cells if c] if not boxes: continue spans, nr, nc = _spans_from_bboxes(boxes) cells = [] for r0, c0, rs, cs, b in spans: try: txt = page.crop(b).extract_text() or "" except ValueError: txt = "" cells.append(Cell(r0, c0, clean_cell(txt), rs, cs)) above = page.crop((0, 0, page.width, max(1, t.bbox[1]))).extract_text() or "" tables.append(RawTable(cells, nr, nc, caption=_caption_from_text(above), source=Path(path).name, page=pno, extractor="pdfplumber", context=above[-600:])) return tables def extract_pdf_camelot(path: str | Path, flavor: str = "lattice") -> list[RawTable]: """Camelot: birleşik hücre metnini copy_text ile yayar; birleşim bilgisi kaybolur.""" import camelot import pdfplumber kw = {"copy_text": ["h", "v"]} if flavor == "lattice" else {} res = camelot.read_pdf(str(path), flavor=flavor, pages="all", **kw) out = [] with pdfplumber.open(str(path)) as pdf: for t in res: df = t.df cells = [Cell(r, c, clean_cell(df.iat[r, c])) for r in range(df.shape[0]) for c in range(df.shape[1])] pno = int(t.page) page = pdf.pages[pno - 1] # camelot koordinatları PDF tabanlıdır (alt-sol orijin) try: x0, y0, x1, y1 = t._bbox top = page.height - y1 above = page.crop((0, 0, page.width, max(1, top))).extract_text() or "" except Exception: above = "" out.append(RawTable(cells, df.shape[0], df.shape[1], caption=_caption_from_text(above), source=Path(path).name, page=pno, extractor=f"camelot-{flavor}", context=above[-600:])) return out def extract_pdf(path, method: str = "auto") -> list[RawTable]: if method == "pdfplumber": return extract_pdf_pdfplumber(path) if method in ("camelot", "camelot-lattice"): return extract_pdf_camelot(path, "lattice") if method == "camelot-stream": return extract_pdf_camelot(path, "stream") tabs = extract_pdf_pdfplumber(path) if not tabs: # çizgisiz tablolar için akış kipine düş try: tabs = extract_pdf_camelot(path, "stream") except Exception: tabs = [] return tabs # ======================================================================== HTML def extract_html(path_or_html: str | Path) -> list[RawTable]: from bs4 import BeautifulSoup p = Path(str(path_or_html)) is_file = len(str(path_or_html)) < 500 and p.exists() html = p.read_text(encoding="utf-8", errors="replace") if is_file else str(path_or_html) soup = BeautifulSoup(html, "lxml") out = [] for ti, table in enumerate(soup.find_all("table")): if table.find_parent("table") is not None: continue # iç içe tablolar ayrı ele alınmaz cap_el = table.find("caption") caption = clean_cell(cap_el.get_text(" ")) if cap_el else "" prev_txt = [] for sib in table.find_all_previous(["p", "h1", "h2", "h3", "h4", "div"], limit=4): prev_txt.insert(0, clean_cell(sib.get_text(" "))) context = "\n".join(prev_txt) if not caption: caption = _caption_from_text(context) occupied, cells, max_c = set(), [], 0 rows = [tr for tr in table.find_all("tr") if tr.find_parent("table") is table and tr.find_parent("tfoot") is None] # : kaynak/not satırları veri değildir for r, tr in enumerate(rows): c = 0 for td in tr.find_all(["td", "th"], recursive=False): while (r, c) in occupied: c += 1 rs = int(td.get("rowspan", 1) or 1) cs = int(td.get("colspan", 1) or 1) cells.append(Cell(r, c, clean_cell(td.get_text(" ")), rs, cs)) for dr in range(rs): for dc in range(cs): occupied.add((r + dr, c + dc)) c += cs max_c = max(max_c, c) if not cells: continue n_rows = max(c.row + c.rowspan for c in cells) name = p.name if is_file else f"html_girdi_{ti + 1}" out.append(RawTable(cells, n_rows, max_c, caption=caption, source=name, extractor="html-bs4", context=context[-600:])) return out # ======================================================================== CSV/XLSX def extract_tabular(path: str | Path) -> list[RawTable]: import pandas as pd p = Path(path) if p.suffix.lower() == ".csv": sample = p.read_text(encoding="utf-8", errors="replace")[:2000] sep = ";" if sample.count(";") > sample.count(",") else "," dfs = {p.stem: pd.read_csv(p, sep=sep, dtype=str, header=None, keep_default_na=False)} else: dfs = pd.read_excel(p, sheet_name=None, dtype=str, header=None) out = [] for name, df in dfs.items(): df = df.fillna("") cells = [Cell(r, c, clean_cell(df.iat[r, c])) for r in range(df.shape[0]) for c in range(df.shape[1])] out.append(RawTable(cells, df.shape[0], df.shape[1], caption=str(name), source=p.name, extractor="pandas")) return out def extract_any(path: str | Path, method: str = "auto") -> list[RawTable]: suf = Path(path).suffix.lower() if suf == ".pdf": return extract_pdf(path, method) if suf in (".html", ".htm", ".xhtml"): return extract_html(path) if suf in (".csv", ".xlsx", ".xls"): return extract_tabular(path) raise ValueError(f"Desteklenmeyen biçim: {suf}") # ======================================================================== çözümleme def _is_data_like(s: str) -> bool: if not s: return False pn = parse_number(s, "tr") return (pn.ok and pn.value is not None) or parse_date(s) is not None def _header_row_count(grid, marks, max_h: int = 3) -> int: """Üstten, veri hücresi oranı düşük olan satırları başlık sayar. Yıl gibi sayısal başlıkları ('2023') veri saymamak için birleşim ve tekrar ipuçları kullanılır.""" n = 0 period_seen = False for r, row in enumerate(grid[:max_h + 1]): vals = row[1:] if len(row) > 1 else row nonempty = [v for v in vals if v] if not nonempty: if period_seen: break # dönem satırından sonra yalnızca etiketli satır: gövdedeki bölüm başlığıdır ('VARLIKLAR') n += 1 continue # satır etiketi boş ve dolu hücrelerin tamamı tarih ise (ör. '31.12.2025 | 31.12.2024') dönem başlığıdır if not row[0] and nonempty and all(parse_date(v) and not parse_number(v, "tr").ok for v in nonempty): n += 1 period_seen = True if n >= max_h: break continue data_like = [v for v in nonempty if _is_data_like(v) and not re.fullmatch(r"(19|20)\d{2}", v)] has_merge = any(m in ("o", "h") for m in marks[r]) if len(data_like) / len(nonempty) >= 0.5 and not (has_merge and r == 0): break # tamamı yıl olan bir satır: ilk sütun da başlık benzeriyse başlıktır n += 1 if n >= max_h: break return max(1, n) def resolve(raw: RawTable) -> ResolvedTable: grid, marks = raw.grid(fill_spans=True) # boş satır/sütunları at keep_rows = [i for i, r in enumerate(grid) if any(x for x in r)] grid = [grid[i] for i in keep_rows] marks = [marks[i] for i in keep_rows] keep_cols = [j for j in range(raw.n_cols) if any(r[j] for r in grid)] grid = [[r[j] for j in keep_cols] for r in grid] marks = [[r[j] for j in keep_cols] for r in marks] if not grid: return ResolvedTable(raw.caption, [], [], raw.source, raw.page, raw.extractor, raw=raw) # Akış (stream) çıkarıcıları tablo üstündeki başlık/açıklama satırlarını da yakalayabilir: # tek hücresi dolu olan baştaki satırları tablo adı veya bağlam olarak ayır. caption, context = raw.caption, raw.context while len(grid) > 2 and len(grid[0]) > 2: filled = [x for x, m in zip(grid[0], marks[0]) if x and m != "h"] if len(set(filled)) != 1: break line = filled[0] if re.match(r"^(tablo|cizelge|table)\s*[\d.:-]", fold(line)) or ( not re.match(r"^(tablo|cizelge|table)\s*[\d.:-]", fold(caption or "")) and not line.startswith("(") and re.search(r"\b(tablosu|cizelgesi|bilanco)\b", fold(line))): caption = line else: context = (context + "\n" + line).strip() grid, marks = grid[1:], marks[1:] raw_caption_ok = re.match(r"^(tablo|cizelge|table)\s*[\d.:-]", fold(caption or "")) if not raw_caption_ok: m = re.search(r"((Tablo|Çizelge|Table)\s*[\d.]+\s*[:.\-–].*)", context or "") if m: caption = m.group(1).strip() nh = _header_row_count(grid, marks, max_h=5) header_parts = [] for j in range(len(keep_cols)): parts = [] for i in range(nh): t = grid[i][j] if t and (not parts or fold(parts[-1]) != fold(t)): parts.append(t) header_parts.append(parts) body = [row[:] for row in grid[nh:]] # gövdesi tamamen boş olup yalnızca başlık parçası taşıyan sütun (satır sonunda bölünmüş başlık sözcüğü, # ör. 'Referansla-' + 'rı'): başlığını soldaki sütuna ekle ve sütunu kaldır j = 1 while j < len(header_parts): if header_parts[j] and body and not any(r[j] for r in body): header_parts[j - 1] = header_parts[j - 1] + [p for p in header_parts[j] if p not in header_parts[j - 1]] del header_parts[j] for r in body: del r[j] keep_cols = keep_cols[:j] + keep_cols[j + 1:] else: j += 1 # tablo sonundaki tek hücreli kaynak/not/dipnot satırları veri değildir while body and len({x for x in body[-1] if x}) == 1 and re.match( r"^(kaynak|not|notlar|aciklama|\(\*+\)|\*)\b", fold(next(x for x in body[-1] if x))): body.pop() # Camelot gibi birleşim bilgisi olmayan çıkarıcılarda, ilk sütundaki boşlukları # (dikey birleşimin kaybolmuş kopyaları) yukarıdaki değerle doldur for j in range(min(2, len(keep_cols))): col = [r[j] for r in body] text_like = sum(1 for v in col if v and not _is_data_like(v)) if text_like >= max(1, 0.5 * len([v for v in col if v])): for i in range(1, len(body)): if not body[i][j] and any(body[i][k] for k in range(len(keep_cols)) if k != j): body[i][j] = body[i - 1][j] merged = sum(1 for c in raw.cells if c.rowspan > 1 or c.colspan > 1) return ResolvedTable(caption, header_parts, body, raw.source, raw.page, raw.extractor, n_header_rows=nh, merged_cells=merged, context=context, raw=raw)