Tüm yazılar
Veri Otomasyonu 5 dk okuma

PDF Tablolarını Veriye Çevirmek: pdfplumber ile Doğrulanabilir Çıkarma

pdfplumber ile PDF tablolarını pandas DataFrame'e çevirme: metin katmanı kontrolü, çok sayfalı tabloları birleştirme, toplam doğrulama ve OCR kararı.

#python #automation #data-pipeline

PDF tablolarını veriye çevirmek, danışmanlık işlerinde en sık karşıma çıkan ve en çok hafife alınan görevlerden biri. Banka ekstresi, tedarikçi faturası, kamu kurumunun yayımladığı aylık rapor: hepsi görünüşte düzgün tablolar içeriyor, ama PDF bir veri formatı değil, bir sayfa tarifi. Dosyanın içinde "tablo" diye bir nesne yok; yalnızca koordinatları belli metin parçaları ve çizgiler var. Tabloyu biz, o parçaların yerleşiminden yeniden kuruyoruz.

Bu yazıda Python tarafında pdfplumber ile kullandığım pratik akışı anlatacağım: önce dosyada metin katmanı olup olmadığını kontrol etmek, sonra sayfa sayfa tablo çıkarıp pandas DataFrame'e normalize etmek, çok sayfaya yayılan ve birleşik hücre içeren tabloları birleştirmek ve en önemlisi, çıkan sonucun doğru olduğunu kanıtlayan bir doğrulama katmanı kurmak. Doğrulama olmadan yapılan çıkarma, işleyen ama güvenilmeyen bir boru hattıdır; bunu erken öğrendim.

Önce metin katmanı var mı diye bakın

İlk soru şu: PDF gerçekten metin içeriyor mu, yoksa taranmış bir görüntü mü? Bir PDF'i açıp içindeki yazıyı fareyle seçebiliyorsanız metin katmanı vardır ve pdfplumber doğrudan işe yarar. Seçemiyorsanız elinizdeki şey aslında bir resimdir; pdfplumber size boş metin döndürür ve tablo diye hiçbir şey bulamaz. Bu ayrımı kod başında yapmazsanız, boş DataFrame'lerle dolu bir çıktıyı "işlem başarılı" diye raporlarsınız.

  • Metin katmanı varsa: pdfplumber, karakter koordinatlarından satır ve sütunları güvenilir biçimde çıkarır. Maliyet neredeyse sıfırdır, işlem saniyeler sürer.
  • Metin katmanı yoksa: OCR'a geçmek gerekir. Tesseract gibi açık kaynak bir motor ücretsizdir ama düzgün taranmamış, eğik veya düşük çözünürlüklü sayfalarda hata oranı belirgin biçimde artar; bulut OCR servisleri daha iyi sonuç verir ama sayfa başına ücret alır ve veriyi dışarı gönderir.
  • Karışık dosyalar: Bazı ekstreler ilk sayfası metin, ekleri taranmış görüntü olarak gelir. Kontrolü dosya başına değil sayfa başına yapmak bu tuzağı önler.

Benim kuralım basit: OCR'ı yalnızca metin katmanı olmadığı kanıtlandığında devreye alıyorum ve OCR'dan geçen her tabloyu daha sıkı bir doğrulamadan geçiriyorum. OCR'ın "8" ile "B", "0" ile "O" karıştırması tutar sütununda felakete dönüşür; bu yüzden OCR çıktısı için toplam kontrolü isteğe bağlı değil, zorunlu.

pdfplumber ile sayfa sayfa çıkarma

pdfplumber'ın extract_tables() fonksiyonu iki strateji sunar: çizgi tabanlı (lines) ve metin tabanlı (text). Bankaların ve resmî kurumların ürettiği tabloların çoğunda hücre çizgileri vardır, bu yüzden lines stratejisiyle başlıyorum; çizgisiz tablolarda text stratejisine geçip snap_tolerance değeriyle oynuyorum. Aşağıdaki çıkarıcı üç şeyi birden yapıyor: metin katmanını kontrol ediyor, her sayfadaki tabloları okuyor ve tekrar eden başlık satırlarını atarak hepsini tek DataFrame'de birleştiriyor.

import pdfplumber
import pandas as pd


def has_text_layer(pdf_path, min_chars=50):
    """Ilk sayfalarda anlamli metin var mi? Yoksa taranmis PDF'dir."""
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages[:3]:
            text = page.extract_text() or ""
            if len(text.strip()) >= min_chars:
                return True
    return False


def extract_tables(pdf_path, expected_columns):
    """Her sayfadaki tablolari okur, tek DataFrame'e birlestirir."""
    settings = {
        "vertical_strategy": "lines",
        "horizontal_strategy": "lines",
        "snap_tolerance": 3,
    }
    frames = []
    header = None
    with pdfplumber.open(pdf_path) as pdf:
        for page_no, page in enumerate(pdf.pages, start=1):
            for table in page.extract_tables(table_settings=settings):
                rows = [[(c or "").replace("\n", " ").strip() for c in row]
                        for row in table]
                rows = [r for r in rows if any(r)]
                if not rows:
                    continue
                if header is None:
                    header = rows[0]
                    if len(header) != expected_columns:
                        raise ValueError(f"Baslik {len(header)} sutun, beklenen {expected_columns}")
                    rows = rows[1:]
                elif rows[0] == header:
                    rows = rows[1:]  # sonraki sayfada tekrar eden baslik
                rows = [r for r in rows if len(r) == expected_columns]
                df = pd.DataFrame(rows, columns=header)
                df["sayfa"] = page_no
                frames.append(df)
    if not frames:
        return pd.DataFrame()
    return pd.concat(frames, ignore_index=True)


if __name__ == "__main__":
    yol = "ekstre.pdf"
    if not has_text_layer(yol):
        raise SystemExit("Metin katmani yok: OCR adimina yonlendir.")
    df = extract_tables(yol, expected_columns=4)
    print(df.head())

Burada birkaç kararı özellikle bilinçli verdim:

  • Birleşik hücreler: pdfplumber, birleşik veya boş hücreleri None döndürür. (c or "") ifadesi bunu boş metne çevirir; böylece satır uzunluğu bozulmaz ve doğrulama katmanı boş hücre oranını ölçebilir.
  • Hücre içi satır sonları: Açıklama sütunları çoğu zaman iki satıra sarar. \n karakterini boşluğa çevirmezseniz aynı işlem CSV'de iki satır gibi görünür.
  • Çok sayfaya yayılan tablolar: Ekstrelerde tablo her sayfada başlığını tekrar eder. Başlığı yalnızca ilk kez alıp sonraki sayfalarda tekrarını atmak, birleştirmenin en güvenli yolu. sayfa sütunu ise bir satırın hangi sayfadan geldiğini kaydeder; sorun çıktığında kaynağa dönmek için bu bilgi paha biçilmez.
  • Beklenen sütun sayısı: Eksik veya fazla hücreli satırları sessizce düşürmek yerine sayıyorum; doğrulama katmanı bu farkı raporluyor.

Çıkarılan tabloyu doğrulayan katman

Çıkarmanın çalışması ile doğru çalışması arasındaki fark, bir sonraki adımda ortaya çıkar. Bir PDF'de sütun çizgisi biraz kayıksa pdfplumber tutar sütununu ikiye bölebilir, bir hücreyi komşusuna yapıştırabilir veya bir satırı tamamen atlayabilir. Bunların hiçbiri hata fırlatmaz. Excel dosyalarında kalite kontrolü otomatikleştirirken kullandığım mantığın aynısını burada da uyguluyorum: yapısal kontroller, içerik kontrolleri ve bir bağımsız referansla çapraz kontrol.

En değerli çapraz kontrol, PDF'in kendi beyan ettiği toplamdır. Ekstre ve faturaların neredeyse hepsinde sayfa sonunda veya belge sonunda "TOPLAM" satırı bulunur. Bu satırı tablo olarak değil, sayfa metninden düzenli ifadeyle yakalıyorum; log analizinde kullandığım regex kalıplarının burada da işe yaradığını gördüm. Çıkardığım satırların toplamı beyan edilen toplamla uyuşuyorsa tablo büyük ihtimalle doğrudur; uyuşmuyorsa nerede kaydığını aramaya başlarım.

import re
                import pdfplumber
                import pandas as pd


                def to_number(value):
                """'1.234,56' veya '-1.234,56 TL' gibi Turkce bicimleri float'a cevirir."""
                s = re.sub(r"[^\d,\-.]", "", str(value))
                if not s:
                return None
                s = s.replace(".", "").replace(",", ".")
                try:
                return float(s)
                except ValueError:
                return None


                def find_declared_total(pdf_path, label="TOPLAM"):
                """Sayfa metninde 'TOPLAM 12.345,67' gibi bir satir arar; bulamazsa None."""
                pattern = re.compile(label + r"\s*:?\s*(-?[\d.]+,\d{2})", re.IGNORECASE)
                with pdfplumber.open(pdf_path) as pdf:
                for page in reversed(pdf.pages):
                match = pattern.search(page.extract_text() or "")
                if match:
                return to_number(match.group(1))
                return None


                def validate(df, expected_columns, amount_col, declared_total=None,
                max_empty_ratio=0.05, tolerance=0.01):
                """Cikarilan tablo icin denetimler; bos liste donerse tablo temizdir."""
                problems = []
                data_cols = [c for c in df.columns if c != "sayfa"]

                if len(data_cols) != expected_columns:
                problems.append(f"Sutun sayisi {len(data_cols)}, beklenen {expected_columns}")

                if df.empty:
                problems.append("Hic satir cikarilamadi")
                return problems

                empty_ratio = (df[data_cols] == "").sum().sum() / df[data_cols].size
                if empty_ratio > max_empty_ratio:
                problems.append(f"Bos hucre orani yuksek: {empty_ratio:.1%}")

                amounts = df[amount_col].map(to_number)
                unparsed = int(amounts.isna().sum())
                if unparsed:
                problems.append(f"{unparsed} satirda tutar sayiya cevrilemedi")

                if declared_total is not None:
                diff = abs(amounts.fillna(0).sum() - declared_total)
                if diff > tolerance:
                problems.append(f"Toplam uyusmuyor: fark {diff:.2f}")

                return problems


                if __name__ == "__main__":
                yol = "ekstre.pdf"
                df = extract_tables(yol, expected_columns=4)  # onceki ornekteki fonksiyon
                sorunlar = validate(df, expected_columns=4, amount_col="Tutar",
                declared_total=find_declared_total(yol))
                if sorunlar:
                raise SystemExit("DOGRULAMA BASARISIZ: " + "; ".join(sorunlar))
                df.to_csv("ekstre.csv", index=False)
                print(f"{len(df)} satir dogrulandi ve kaydedildi.")
                

Bu katmanın çalışma ilkesi şu: doğrulama geçmeyen tablo diske yazılmaz. İş akışını böyle kurunca, "geçen ay 40 satır eksik aktarılmış" türünden keşifler ortadan kalkıyor, çünkü eksik aktarım daha ilk gün duruyor ve sesli biçimde duruyor. Toplam kontrolü için tolerans değerini kuruş yuvarlamasını affedecek kadar küçük, gerçek bir satır kaybını yakalayacak kadar dar tutuyorum.

Bir uyarı: beyan edilen toplam her zaman aynı kalemleri kapsamaz. Bazı ekstrelerde "TOPLAM" yalnızca o sayfayı, bazılarında tüm dönemi ifade eder; kimi faturalarda KDV hariç ve KDV dahil iki ayrı toplam vardır. Doğrulamayı yazmadan önce belgeyi bir kez elle okuyup hangi toplamın neyi kapsadığını not etmek, sonradan saatler kazandırıyor.

KVKK: bu PDF'ler kişisel veri taşır

Banka ekstresi ve fatura, adı üstünde, kişisel veridir: ad soyad, hesap numarası, IBAN, harcama alışkanlığı. Bu dosyaları işleyen bir boru hattı kurarken teknik doğruluk kadar hukuki çerçeve de sizin sorumluluğunuzdadır. Yaptığım işlerde şu ilkelerden şaşmıyorum:

  • Amaçla sınırlı işleme: Yalnızca mutabakat için gereken sütunlar çıkarılır; kart numarası veya adres gibi işe yaramayan alanlar DataFrame'e hiç alınmaz.
  • Saklama süresi: Kaynak PDF ve ara CSV dosyaları, iş bittikten sonra tanımlı bir süre içinde silinir; "belki lazım olur" diye tutulan klasörler en büyük risktir.
  • Yurt dışına aktarım: Bulut OCR servisi kullanmak, kişisel veriyi yurt dışına göndermek demektir. Bu karar teknik değil hukuki bir karardır ve veri sorumlusunun açık onayı olmadan verilmemelidir. Metin katmanı olan dosyalarda pdfplumber tamamen yerel çalıştığı için bu sorun hiç doğmaz; OCR'a geçmeden önce yerel Tesseract'ı denemek de bu yüzden önemlidir.

Sonuç

PDF'den tablo çıkarmak, doğru araçla kolay ama doğrulamasız yapıldığında tehlikeli bir iştir. Akış üç adımda özetlenebilir: metin katmanı var mı diye bak ve yoksa OCR kararını bilinçli ver; pdfplumber ile sayfa sayfa çıkarıp başlık tekrarlarını ve birleşik hücreleri temizleyerek tek DataFrame'e indir; sütun sayısı, boş hücre oranı ve beyan edilen toplam üzerinden doğrulamayı geçmeyen hiçbir tabloyu diske yazma. Bu üçlü, elle kopyala-yapıştırla saatler süren ve gözden kaçan hatalarla dolu bir işi, dakikalar içinde biten ve kendi kendini denetleyen bir rutine çevirir.

Elinizde düzenli olarak işlenmesi gereken ekstre, fatura veya rapor PDF'leri varsa ve bunları güvenilir bir veri akışına dönüştürmek istiyorsanız, veri ve otomasyon danışmanlığı kapsamında böyle boru hatlarını kuruyorum. Kendi dosyalarınız üzerinde nasıl çalışacağını konuşmak için iletişim sayfasından bana yazabilirsiniz.