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:
.wasmuzantılı dosyaları Nginx veya CDN’deapplication/wasmMIME type ile servis etmelisiniz, aksi halde streaming compilation çalışmaz.Cache-Control: max-age=31536000, immutableile 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-originveCross-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. Herpage.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.
