WebAssembly ile Tarayıcıda PDF Oluşturma ve Düzenleme

Tarayıcıda PDF üretmek, yıllarca “ya sunucuya gönder ya da kullanıcıya acı çektir” ikilemiyle geçti. Headless Chrome spawn etmek, Puppeteer sunucusunu ayakta tutmak, wkhtmltopdf bağımlılıklarıyla boğuşmak… Bunların hepsini yaşadıysanız ne demek istediğimi anlıyorsunuzdur. WebAssembly bu denklemi köklü biçimde değiştiriyor. Artık PDF oluşturma ve düzenleme işlemlerini doğrudan tarayıcıda, sunucu maliyeti olmadan, kullanıcı verisini dışarıya göndermeden yapabiliyoruz.

Bu yazıda gerçek bir proje üzerinden konuyu ele alacağım: Müşteri faturalarını tarayıcıda oluşturup imzalayan, sonra da mevcut PDF’lere form alanı ekleyen bir sistem. Hem teknik detaylara gireceğiz hem de production’da karşılaştığım sorunları ve çözümlerini paylaşacağım.

WebAssembly ile PDF İşlemenin Temeli

PDF işleme için WASM ekosisteminde birkaç seçenek var. Ben çoğunlukla iki kütüphaneyle çalışıyorum:

  • pdf-lib: Pure JavaScript, WASM bağımlılığı yok ama WASM ile birlikte kullanılabiliyor
  • PDFium WASM portu: Google’ın PDFium motorunun WASM’a derlenmiş hali, rendering için güçlü
  • MuPDF.js: Artifex’in MuPDF kütüphanesinin resmi WASM portu, hem görüntüleme hem düzenleme
  • jsPDF: Eski ama hala kullanılan, basit kullanım senaryoları için yeterli

Asıl güç ise C/C++ ile yazılmış bir PDF kütüphanesini kendiniz WASM’a derlediğinizde ortaya çıkıyor. Emscripten toolchain ile libpoppler ya da libmupdf’i compile ettiğinizde, tarayıcıda neredeyse native hızında PDF işleme elde ediyorsunuz.

Önce basit bir kurulum ile başlayalım:

# Node.js projesi için gerekli paketler
npm init -y
npm install pdf-lib @pdf-lib/fontkit
npm install --save-dev webpack webpack-cli

# MuPDF.js için
npm install mupdf

# Eğer Emscripten ile custom build yapacaksanız
# emsdk kurulumu
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

pdf-lib ile Temel PDF Oluşturma

Teoriden pratiğe geçelim. Bir fatura oluşturucu yazıyoruz. Bu senaryo gerçek: E-ticaret panelinde kullanıcılar kendi faturalarını tarayıcıda üretip indiriyor, sunucuya hiçbir şey gönderilmiyor.

// invoice-generator.js
import { PDFDocument, rgb, StandardFonts, degrees } from 'pdf-lib';

async function createInvoice(invoiceData) {
  const pdfDoc = await PDFDocument.create();
  
  // Türkçe karakter desteği için fontkit kaydı
  const { default: fontkit } = await import('@pdf-lib/fontkit');
  pdfDoc.registerFontkit(fontkit);
  
  // Özel font yükleme (Türkçe karakter için kritik)
  const fontBytes = await fetch('/fonts/NotoSans-Regular.ttf')
    .then(res => res.arrayBuffer());
  const customFont = await pdfDoc.embedFont(fontBytes);
  
  const page = pdfDoc.addPage([595.28, 841.89]); // A4 boyutu
  const { width, height } = page.getSize();
  
  // Başlık alanı
  page.drawRectangle({
    x: 0,
    y: height - 120,
    width: width,
    height: 120,
    color: rgb(0.13, 0.29, 0.53),
  });
  
  page.drawText('FATURA', {
    x: 50,
    y: height - 70,
    size: 32,
    font: customFont,
    color: rgb(1, 1, 1),
  });
  
  page.drawText(`Fatura No: ${invoiceData.invoiceNumber}`, {
    x: 50,
    y: height - 100,
    size: 12,
    font: customFont,
    color: rgb(0.9, 0.9, 0.9),
  });
  
  // Müşteri bilgileri
  page.drawText('Sayın:', {
    x: 50,
    y: height - 160,
    size: 11,
    font: customFont,
    color: rgb(0.3, 0.3, 0.3),
  });
  
  page.drawText(invoiceData.customerName, {
    x: 50,
    y: height - 178,
    size: 13,
    font: customFont,
    color: rgb(0, 0, 0),
  });
  
  // Ürün tablosu çizimi
  let yPosition = height - 280;
  const tableHeaders = ['Ürün/Hizmet', 'Miktar', 'Birim Fiyat', 'Toplam'];
  const colPositions = [50, 280, 370, 460];
  
  // Tablo başlığı arka planı
  page.drawRectangle({
    x: 40,
    y: yPosition - 5,
    width: width - 80,
    height: 25,
    color: rgb(0.93, 0.93, 0.93),
  });
  
  tableHeaders.forEach((header, idx) => {
    page.drawText(header, {
      x: colPositions[idx],
      y: yPosition,
      size: 10,
      font: customFont,
      color: rgb(0.2, 0.2, 0.2),
    });
  });
  
  yPosition -= 30;
  
  // Ürün satırları
  invoiceData.items.forEach((item, rowIdx) => {
    if (rowIdx % 2 === 0) {
      page.drawRectangle({
        x: 40,
        y: yPosition - 5,
        width: width - 80,
        height: 22,
        color: rgb(0.97, 0.97, 0.99),
      });
    }
    
    const rowData = [
      item.description,
      item.quantity.toString(),
      `${item.unitPrice.toFixed(2)} TL`,
      `${(item.quantity * item.unitPrice).toFixed(2)} TL`,
    ];
    
    rowData.forEach((cell, colIdx) => {
      page.drawText(cell, {
        x: colPositions[colIdx],
        y: yPosition,
        size: 10,
        font: customFont,
        color: rgb(0, 0, 0),
      });
    });
    
    yPosition -= 25;
  });
  
  const pdfBytes = await pdfDoc.save();
  return pdfBytes;
}

// Kullanım
const invoiceData = {
  invoiceNumber: 'INV-2024-0042',
  customerName: 'Ahmet Yılmaz',
  items: [
    { description: 'Web Tasarım Hizmeti', quantity: 1, unitPrice: 5000 },
    { description: 'SEO Danışmanlığı', quantity: 3, unitPrice: 1500 },
  ]
};

const pdfBytes = await createInvoice(invoiceData);
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
window.open(url);

Mevcut PDF’leri Düzenleme ve Form Alanı Ekleme

Sadece yeni PDF oluşturmak değil, mevcut belgelere müdahale etmek de sık karşılaşılan bir senaryo. Devlet kurumlarının form PDF’lerini otomatik doldurmak ya da imza alanı eklemek bunların başında geliyor.

// pdf-editor.js
import { PDFDocument, PDFTextField, rgb } from 'pdf-lib';

async function addFormFieldsToExistingPDF(existingPdfUrl) {
  // Mevcut PDF'i yükle
  const existingPdfBytes = await fetch(existingPdfUrl)
    .then(res => res.arrayBuffer());
  
  const pdfDoc = await PDFDocument.load(existingPdfBytes);
  const form = pdfDoc.getForm();
  const pages = pdfDoc.getPages();
  const firstPage = pages[0];
  const { height } = firstPage.getSize();
  
  // Metin alanı ekle
  const nameField = form.createTextField('applicant.fullname');
  nameField.setText('');
  nameField.addToPage(firstPage, {
    x: 150,
    y: height - 245,
    width: 300,
    height: 22,
    textColor: rgb(0, 0, 0),
    backgroundColor: rgb(0.95, 0.95, 1),
    borderColor: rgb(0.5, 0.5, 0.8),
    borderWidth: 1,
  });
  
  // Tarih alanı
  const dateField = form.createTextField('application.date');
  dateField.setText(new Date().toLocaleDateString('tr-TR'));
  dateField.addToPage(firstPage, {
    x: 380,
    y: height - 245,
    width: 120,
    height: 22,
    textColor: rgb(0, 0, 0),
    backgroundColor: rgb(0.95, 1, 0.95),
    borderColor: rgb(0.3, 0.7, 0.3),
    borderWidth: 1,
  });
  
  // Checkbox ekle
  const agreementCheck = form.createCheckBox('terms.agreement');
  agreementCheck.addToPage(firstPage, {
    x: 55,
    y: height - 580,
    width: 15,
    height: 15,
  });
  
  // Açılır liste
  const cityDropdown = form.createDropdown('applicant.city');
  cityDropdown.addOptions([
    'İstanbul', 'Ankara', 'İzmir', 'Bursa', 'Antalya'
  ]);
  cityDropdown.select('İstanbul');
  cityDropdown.addToPage(firstPage, {
    x: 150,
    y: height - 310,
    width: 200,
    height: 22,
  });
  
  return await pdfDoc.save();
}

WASM ile Performans Kritik PDF Rendering

Binlerce sayfalık PDF’leri tarayıcıda göstermek için MuPDF’in WASM portunu kullanıyorum. Bu kütüphane özellikle büyük belgeler için çok daha performanslı.

// mupdf-renderer.js
// npm install mupdf sonrası kullanım

let mupdfInstance = null;

async function initMuPDF() {
  if (mupdfInstance) return mupdfInstance;
  
  // Dynamic import ile WASM modülü yükle
  const mupdf = await import('mupdf');
  await mupdf.ready;
  mupdfInstance = mupdf;
  return mupdf;
}

async function renderPageToCanvas(pdfBuffer, pageNumber, scale = 1.5) {
  const mupdf = await initMuPDF();
  
  // PDF belgesini yükle
  const doc = mupdf.Document.openDocument(
    new Uint8Array(pdfBuffer), 
    'application/pdf'
  );
  
  const page = doc.loadPage(pageNumber - 1); // 0-indexed
  
  // Sayfa boyutlarını al
  const bounds = page.getBounds();
  const pixelWidth = Math.ceil((bounds[2] - bounds[0]) * scale);
  const pixelHeight = Math.ceil((bounds[3] - bounds[1]) * scale);
  
  // Piksel buffer oluştur
  const pixmap = new mupdf.Pixmap(
    mupdf.ColorSpace.DeviceRGB, 
    [0, 0, pixelWidth, pixelHeight], 
    false
  );
  pixmap.clear(255);
  
  // Transform matrix ile render et
  const matrix = mupdf.Matrix.scale(scale, scale);
  const device = new mupdf.DrawDevice(matrix, pixmap);
  page.run(device, mupdf.Matrix.identity, null);
  device.close();
  
  // Canvas'a çiz
  const canvas = document.createElement('canvas');
  canvas.width = pixelWidth;
  canvas.height = pixelHeight;
  const ctx = canvas.getContext('2d');
  
  const imageData = new ImageData(
    new Uint8ClampedArray(pixmap.getPixels()),
    pixelWidth,
    pixelHeight
  );
  ctx.putImageData(imageData, 0, 0);
  
  // Temizlik
  pixmap.destroy();
  page.destroy();
  doc.destroy();
  
  return canvas;
}

Web Worker ile Non-Blocking PDF İşleme

Production’da öğrendiğim en önemli ders şu: PDF işleme kesinlikle ana thread’de yapılmamalı. 10 MB’lık bir PDF’i ana thread’de işlemeye çalıştığınızda UI tamamen donuyor ve kullanıcı deneyimi mahvoluyor.

// pdf-worker.js (ayrı dosya olarak)
import { PDFDocument, rgb } from 'pdf-lib';

self.onmessage = async (event) => {
  const { type, payload, taskId } = event.data;
  
  try {
    let result;
    
    switch (type) {
      case 'CREATE_INVOICE':
        result = await createInvoiceInWorker(payload);
        break;
      case 'MERGE_PDFS':
        result = await mergePDFs(payload.pdfBuffers);
        break;
      case 'COMPRESS_PDF':
        result = await compressPDF(payload.pdfBuffer);
        break;
      default:
        throw new Error(`Bilinmeyen işlem tipi: ${type}`);
    }
    
    self.postMessage({ 
      taskId, 
      success: true, 
      result 
    }, result instanceof ArrayBuffer ? [result] : []);
    
  } catch (error) {
    self.postMessage({ 
      taskId, 
      success: false, 
      error: error.message 
    });
  }
};

async function mergePDFs(pdfBuffers) {
  const mergedDoc = await PDFDocument.create();
  
  for (const buffer of pdfBuffers) {
    const doc = await PDFDocument.load(buffer);
    const pageCount = doc.getPageCount();
    const pageIndices = Array.from({ length: pageCount }, (_, i) => i);
    const copiedPages = await mergedDoc.copyPagesFrom(doc, pageIndices);
    copiedPages.forEach(page => mergedDoc.addPage(page));
  }
  
  const mergedBytes = await mergedDoc.save();
  return mergedBytes.buffer;
}
// main-thread.js - Worker'ı kullanan taraf
class PDFWorkerManager {
  constructor() {
    this.worker = new Worker(
      new URL('./pdf-worker.js', import.meta.url),
      { type: 'module' }
    );
    this.pendingTasks = new Map();
    this.taskCounter = 0;
    
    this.worker.onmessage = (event) => {
      const { taskId, success, result, error } = event.data;
      const task = this.pendingTasks.get(taskId);
      
      if (task) {
        if (success) {
          task.resolve(result);
        } else {
          task.reject(new Error(error));
        }
        this.pendingTasks.delete(taskId);
      }
    };
  }
  
  async executeTask(type, payload) {
    const taskId = ++this.taskCounter;
    
    return new Promise((resolve, reject) => {
      this.pendingTasks.set(taskId, { resolve, reject });
      
      const transferables = payload.pdfBuffer instanceof ArrayBuffer 
        ? [payload.pdfBuffer] 
        : [];
      
      this.worker.postMessage({ type, payload, taskId }, transferables);
      
      // 30 saniye timeout
      setTimeout(() => {
        if (this.pendingTasks.has(taskId)) {
          this.pendingTasks.delete(taskId);
          reject(new Error('PDF işleme zaman aşımına uğradı'));
        }
      }, 30000);
    });
  }
  
  async mergePDFs(pdfBuffers) {
    return this.executeTask('MERGE_PDFS', { pdfBuffers });
  }
  
  terminate() {
    this.worker.terminate();
  }
}

// Kullanım örneği
const pdfManager = new PDFWorkerManager();

document.getElementById('merge-btn').addEventListener('click', async () => {
  const files = document.getElementById('pdf-files').files;
  const buffers = await Promise.all(
    Array.from(files).map(f => f.arrayBuffer())
  );
  
  const loadingEl = document.getElementById('loading');
  loadingEl.style.display = 'block';
  
  try {
    const mergedBuffer = await pdfManager.mergePDFs(buffers);
    const blob = new Blob([mergedBuffer], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    
    const a = document.createElement('a');
    a.href = url;
    a.download = 'birlestirilmis.pdf';
    a.click();
  } catch (err) {
    console.error('Birleştirme hatası:', err);
  } finally {
    loadingEl.style.display = 'none';
  }
});

Dijital İmza Entegrasyonu

Son olarak, üretim ortamında çok sorduğum bir konu: WASM tarafında PDF’e dijital imza eklemek. Bu kısım biraz daha karmaşık çünkü gerçek PKI altyapısına dokunuyor.

// pdf-signer.js
import { PDFDocument, rgb } from 'pdf-lib';

async function addVisualSignature(pdfBuffer, signatureConfig) {
  const pdfDoc = await PDFDocument.load(pdfBuffer);
  const { default: fontkit } = await import('@pdf-lib/fontkit');
  pdfDoc.registerFontkit(fontkit);
  
  const pages = pdfDoc.getPages();
  const targetPage = pages[signatureConfig.pageIndex || pages.length - 1];
  const { width, height } = targetPage.getSize();
  
  // İmza kutusu çiz
  targetPage.drawRectangle({
    x: signatureConfig.x || width - 220,
    y: signatureConfig.y || 60,
    width: 200,
    height: 70,
    borderColor: rgb(0.2, 0.4, 0.8),
    borderWidth: 1.5,
    color: rgb(0.96, 0.97, 1),
  });
  
  // İmzalayan ismi
  const helvetica = await pdfDoc.embedFont('Helvetica');
  
  targetPage.drawText(signatureConfig.signerName, {
    x: (signatureConfig.x || width - 220) + 10,
    y: (signatureConfig.y || 60) + 45,
    size: 11,
    font: helvetica,
    color: rgb(0.1, 0.1, 0.5),
  });
  
  // İmza tarihi
  const signDate = new Date().toLocaleString('tr-TR', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  });
  
  targetPage.drawText(`İmzalandı: ${signDate}`, {
    x: (signatureConfig.x || width - 220) + 10,
    y: (signatureConfig.y || 60) + 28,
    size: 8,
    font: helvetica,
    color: rgb(0.4, 0.4, 0.4),
  });
  
  // Eğer imza görseli varsa embed et
  if (signatureConfig.signatureImageBytes) {
    const signatureImage = await pdfDoc.embedPng(
      signatureConfig.signatureImageBytes
    );
    
    targetPage.drawImage(signatureImage, {
      x: (signatureConfig.x || width - 220) + 10,
      y: (signatureConfig.y || 60) + 5,
      width: 120,
      height: 20,
    });
  }
  
  // Metadata güncelle
  pdfDoc.setSubject(`İmzalayan: ${signatureConfig.signerName}`);
  pdfDoc.setKeywords(['imzalı', 'dijital-imza', signatureConfig.signerName]);
  
  return await pdfDoc.save({
    updateFieldAppearances: true,
  });
}

Production’da Dikkat Edilmesi Gerekenler

WASM modüllerini production’a taşırken karşılaştığım sorunlar ve çözümleri:

  • WASM dosyası cache’leme: .wasm uzantılı dosyaları Nginx veya CDN’de application/wasm MIME type ile servis etmelisiniz, aksi halde streaming compilation çalışmaz. Cache-Control: max-age=31536000, immutable ile de agresif cache uygulayın.
  • SharedArrayBuffer ve COOP/COEP başlıkları: Eğer Web Worker’lar arasında shared memory kullanacaksanız bu iki header zorunlu: Cross-Origin-Opener-Policy: same-origin ve Cross-Origin-Embedder-Policy: require-corp. Nginx config’e eklemeyi unutmayın.
  • Memory sızıntıları: MuPDF kullandığınızda destroy() çağrılarını ihmal etmeyin. Her page.destroy(), doc.destroy(), pixmap.destroy() önemli. Bir PDF viewer yazdım, 50 sayfa sonra tab çöküyordu, nedeni buydu.
  • Büyük dosyalar için chunk bazlı işleme: 50 MB üzeri PDF’lerde tek seferde load etmek yerine sayfa sayfa okuyun. PDFDocument.load() metoduna { throwOnInvalidObject: false } opsiyonunu geçmek bazı corrupt PDF’lerin okunabilmesini sağlıyor.
  • Font cache: Her PDF oluşturmada font dosyasını tekrar fetch etmeyin. Module scope’ta bir font cache mekanizması oluşturun, özellikle toplu fatura üretiminde bu ciddi fark yaratıyor.

Sonuç

WebAssembly, PDF işlemeyi sunucu bağımlılığından kurtarıp tamamen client-side bir operasyona dönüştürdü. Benim geçişim kademeli oldu: Önce basit fatura oluşturma, sonra form doldurma, sonra PDF merge servisi. Her aşamada sunucu yüküm düştü, kullanıcı verisi korunması arttı, uygulama offline bile çalışır hale geldi.

Şu an kullandığım stack: Basit belgeler için pdf-lib, büyük belge rendering için MuPDF.js WASM. İkisi bir arada gayet uyumlu çalışıyor. Eğer siz de PDF sunucunuzun maliyetinden ya da Puppeteer’ın bakım yükünden bıktıysanız, WASM tarafını ciddi değerlendirmenizi öneririm. Başlangıç eğrisi var ama bir kez oturttunuz mu, geriye dönmek istemiyorsunuz.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir