diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 43918d6..9d3bcd6 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -327,3 +327,11 @@ Güncel backend için ayrı localhost:5174 QA örneği açıldı (önceki 5173 Tarayıcı testinde bulunan İngilizce PDF istisnası kullanıcıya yönelik Türkçe mesaja dönüştürüldü; depodaki eski 8 MB metni sunucunun 10 MB sınırıyla eşitlendi. Türkçe hata tarayıcıda tekrar doğrulandı. 72 test ve üretim derlemesi geçti. Kanıt: evidence/qa-document-upload.txt (ilk hata metni dahil), evidence/qa-document-vault-retry.txt (düzeltme sonrası). Test dosyaları tests/fixtures/document-readable.pdf ve document-broken.pdf; resmi ihale belgesi değildir. Bu kabul OCR, 40 sayfayı aşan doküman, gerçek cetvel kalem doğruluğu veya beş resmi dosyanın tamamlanması anlamına gelmez. Bunlar halen açık. + +### PDF sayfa bütünlüğü ve karma belge okuma + +Varsayılan 40 metin sayfası ve 8 OCR sayfası sınırı kaldırıldı; tüm sayfalar sırayla okunuyor. Karma PDF'de metni az olan sayfalar ayrı OCR'a gönderiliyor, metinli sayfalar korunuyor. OCR hatası artık başarılı metin okuma sonucuna çevrilmiyor; hata sayfa numarasıyla kullanıcıya dönüyor. Sonuç sayfa bazında metin/OCR yöntemini ve bütünlük durumunu taşıyor. Açık maxPages kullanan düşük seviye çağrı kısmi sonucu complete:false olarak işaretliyor; uygulamanın mevcut çağrılarında böyle bir sınır yok. Sayfa, canvas, OCR worker ve pdfjs yükleme görevi kaynakları serbest bırakılıyor. + +76 test ve üretim derlemesi geçti. Testler 45 sayfalık gerçek PDF baytlarını pdfjs ile okuyarak son sayfa maddesinin korunduğunu; karma sayfa sırasını ve OCR hatasının başarısızlık olarak iletildiğini doğruluyor. Gerçek PDF testi kütüphanede DocumentProxy.destroy bulunmadığını ortaya çıkardı; kapatma LoadingTask.destroy üzerinden düzeltildi. + +Sınırlar: gerçek tarayıcıda uzun/taranmış karma PDF performansı ve OCR doğruluğu kabulü açık. Sayfa okunmuş olması OCR metninin anlamsal doğruluğunu kanıtlamaz. OCR varsayılanı metni 100 karakterden kısa sayfalarda çalışır; küçük metinli sayfalarda ek işlem maliyeti oluşabilir. Resmi dosya ve beş ihale kabulü tamamlanmadı. diff --git a/src/services/pdfDocumentReader.js b/src/services/pdfDocumentReader.js new file mode 100644 index 0000000..2a22b0b --- /dev/null +++ b/src/services/pdfDocumentReader.js @@ -0,0 +1,23 @@ +/** Read every requested page in order, retaining text/OCR provenance per page. */ +export async function readPdfPages(doc, {maxPages=Infinity, ocrPage, forceOcr=false, onProgress}={}) { + if (!(maxPages === Infinity || (Number.isInteger(maxPages) && maxPages > 0))) throw new Error('Geçersiz sayfa sınırı.'); + const pages = Math.min(doc.numPages,maxPages); + const results=[]; + for(let number=1;number<=pages;number++) { + onProgress?.({sayfa:number,toplam:pages}); + const page=await doc.getPage(number); + try { + const content=await page.getTextContent(); + let text=content.items.map(item=>item.str + (item.hasEOL ? '\n' : ' ')).join('').trim(); + let method='text'; + if(ocrPage && (forceOcr || text.replace(/\s/g,'').length < 100)) { + try { text=await ocrPage(page); method='ocr'; } + catch { throw new Error(`${number}. sayfada OCR tamamlanamadı. Okunabilir belgeyle yeniden deneyin.`); } + } + results.push({page:number,text,method}); + } finally { page.cleanup?.(); } + } + return {text:results.map(p=>p.text).join('\n\n').replace(/[ \t]+/g,' ').trim(), + pageCount:doc.numPages,usedPages:pages,pages:results,ocr:results.some(p=>p.method==='ocr'), + complete:pages===doc.numPages}; +} diff --git a/src/services/pdfText.js b/src/services/pdfText.js index 4e9e3e4..019e6a5 100644 --- a/src/services/pdfText.js +++ b/src/services/pdfText.js @@ -1,80 +1,52 @@ -/** - * PDF'ten metin çıkarma (tarayıcı içinde, pdfjs-dist ile). - * Dinamik import edilir — ana bundle'a girmez. - */ +import { readPdfPages } from './pdfDocumentReader.js'; -export async function extractPdfText(file, { maxPages = 40 } = {}) { +async function openPdf(file) { const pdfjs = await import('pdfjs-dist'); - // Worker'ı Vite'ın bundle ettiği dosyadan al (CDN'e çıkmaz) - const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default; - pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; - - const buf = await file.arrayBuffer(); - let doc; - try { doc = await pdfjs.getDocument({ data: buf }).promise; } - catch (error) { - if (error.name === 'InvalidPDFException') throw new Error('PDF dosyası bozuk veya geçerli PDF biçiminde değil.'); - if (error.name === 'PasswordException') throw new Error('PDF parola korumalı; okunabilir bir kopyasını yükleyin.'); + pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default; + let task; + try { + task = pdfjs.getDocument({data:await file.arrayBuffer()}); + return {doc:await task.promise,task}; + } + catch(error) { + await task?.destroy(); + if(error.name === 'InvalidPDFException') throw new Error('PDF dosyası bozuk veya geçerli PDF biçiminde değil.'); + if(error.name === 'PasswordException') throw new Error('PDF parola korumalı; okunabilir bir kopyasını yükleyin.'); throw new Error('PDF okunamadı. Dosyayı kontrol edip yeniden deneyin.'); } - const pages = Math.min(doc.numPages, maxPages); - const parts = []; - for (let i = 1; i <= pages; i++) { - const page = await doc.getPage(i); - const content = await page.getTextContent(); - parts.push(content.items.map((it) => it.str).join(' ')); - } - const text = parts.join('\n\n').replace(/[ \t]+/g, ' ').trim(); - return { text, pageCount: doc.numPages, usedPages: pages }; } -// Metin dosyaları için basit okuyucu -export async function extractPlainText(file) { - const text = await file.text(); - return { text: text.trim(), pageCount: 1, usedPages: 1 }; -} - -/** - * Taranmış (görüntü) PDF için OCR — tesseract.js, Türkçe dil paketi, - * tarayıcıda. Ölçüldü: sentetik teklif cetvelinde güven 87, sayfa başı - * ~0,3 sn (Node). Yalnızca metin katmanı boşsa devreye girer; ilk 8 sayfa. - */ -export async function ocrPdfText(file, { maxPages = 8, onProgress } = {}) { - const pdfjs = await import('pdfjs-dist'); - const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default; - pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; - const { createWorker } = await import('tesseract.js'); - const doc = await pdfjs.getDocument({ data: await file.arrayBuffer() }).promise; - const pages = Math.min(doc.numPages, maxPages); - const worker = await createWorker('tur'); - const parts = []; +async function readPdf(file,{ocr=false,forceOcr=false,maxPages=Infinity,onProgress}={}) { + const {doc,task}=await openPdf(file); + let worker; try { - for (let i = 1; i <= pages; i++) { - onProgress?.({ sayfa: i, toplam: pages }); - const page = await doc.getPage(i); - const viewport = page.getViewport({ scale: 2 }); // ~150 dpi: OCR için yeterli, hızlı - const canvas = document.createElement('canvas'); - canvas.width = viewport.width; canvas.height = viewport.height; - await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; - const { data } = await worker.recognize(canvas); - parts.push(data.text); - } + const result=await readPdfPages(doc,{maxPages,forceOcr,onProgress,ocrPage:ocr ? async(page)=>{ + if(!worker) { + const {createWorker}=await import('tesseract.js'); + worker=await createWorker('tur'); + } + const viewport=page.getViewport({scale:2}); + const canvas=document.createElement('canvas'); + canvas.width=viewport.width;canvas.height=viewport.height; + try { + await page.render({canvasContext:canvas.getContext('2d'),viewport}).promise; + return (await worker.recognize(canvas)).data.text; + } finally { canvas.width=0;canvas.height=0; } + } : undefined}); + return result; } finally { - await worker.terminate(); + try { if(worker) await worker.terminate(); } + finally { await task.destroy(); } } - const text = parts.join('\n\n').replace(/[ \t]+/g, ' ').trim(); - return { text, pageCount: doc.numPages, usedPages: pages, ocr: true }; } -export async function extractText(file, { ocr = true, onProgress } = {}) { - const name = (file.name || '').toLowerCase(); - if (name.endsWith('.pdf') || file.type === 'application/pdf') { - const r = await extractPdfText(file); - // Metin katmanı yoksa taranmış PDF'tir — OCR'a düş - if (ocr && (r.text || '').replace(/\s/g, '').length < 100) { - try { return await ocrPdfText(file, { onProgress }); } catch { return r; } - } - return r; - } +export const extractPdfText=(file,options={})=>readPdf(file,{...options,ocr:false}); +export const ocrPdfText=(file,options={})=>readPdf(file,{...options,ocr:true,forceOcr:true}); +export async function extractPlainText(file) { + return {text:(await file.text()).trim(),pageCount:1,usedPages:1,complete:true}; +} +export async function extractText(file,{ocr=true,onProgress}={}) { + if((file.name || '').toLowerCase().endsWith('.pdf') || file.type==='application/pdf') + return readPdf(file,{ocr,onProgress}); return extractPlainText(file); } diff --git a/tests/pdf-document-reader.test.mjs b/tests/pdf-document-reader.test.mjs new file mode 100644 index 0000000..b38e28e --- /dev/null +++ b/tests/pdf-document-reader.test.mjs @@ -0,0 +1,39 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {readPdfPages} from '../src/services/pdfDocumentReader.js'; +const page=(text)=>({getTextContent:async()=>({items:[{str:text,hasEOL:true}]}),cleanup(){}}); +test('PDF reader preserves the final requirement beyond former 40 page limit',async()=>{ + const doc={numPages:45,getPage:async n=>page(n===45 ? 'FINAL MANDATORY REQUIREMENT' : `Page ${n}`)}; + const result=await readPdfPages(doc); + assert.equal(result.usedPages,45);assert.equal(result.complete,true); + assert.ok(result.text.endsWith('FINAL MANDATORY REQUIREMENT')); + const partial=await readPdfPages(doc,{maxPages:2}); + assert.equal(partial.complete,false);assert.equal(partial.pageCount,45); +}); +test('mixed text and scanned pages use OCR only where needed and preserve order',async()=>{ + const pages=[page('Official requirement '.repeat(10)),page(''),page('Final clause '.repeat(15))]; + let calls=0; + const result=await readPdfPages({numPages:3,getPage:async n=>pages[n-1]},{ocrPage:async()=>{calls++;return 'SCANNED REQUIREMENT';}}); + assert.equal(calls,1);assert.deepEqual(result.pages.map(p=>p.method),['text','ocr','text']); + assert.match(result.text,/Official requirement[\s\S]*SCANNED REQUIREMENT[\s\S]*Final clause/); +}); +test('OCR failure never returns apparently complete extracted text',async()=>{ + await assert.rejects(readPdfPages({numPages:1,getPage:async()=>page('')},{ocrPage:async()=>{throw Error('worker failed');}}),/1. sayfada OCR tamamlanamadı/); +}); + +test('actual 45-page PDF retains final-page text through pdfjs extraction',async()=>{ + const {jsPDF}=await import('jspdf'); + const {getDocument}=await import('pdfjs-dist/legacy/build/pdf.mjs'); + const pdf=new jsPDF(); + for(let n=1;n<=45;n++) { + if(n>1) pdf.addPage(); + pdf.text(n===45 ? 'FINAL OFFICIAL SOURCE REQUIREMENT' : `Synthetic QA page ${n}`,15,20); + } + const task=getDocument({data:new Uint8Array(pdf.output('arraybuffer')),useSystemFonts:true}); + const doc=await task.promise; + try { + const result=await readPdfPages(doc); + assert.equal(result.pageCount,45);assert.equal(result.usedPages,45); + assert.ok(result.text.endsWith('FINAL OFFICIAL SOURCE REQUIREMENT')); + } finally { await task.destroy(); } +});