File size: 15,626 Bytes
a72d1cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
"""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]  # <tfoot>: 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)