Read complete PDFs and surface per-page OCR failures

This commit is contained in:
2026-09-06 09:42:35 +03:00
parent 01d4acf246
commit b340cf5772
4 changed files with 109 additions and 67 deletions
+8
View File
@@ -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. 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. 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ı.
+23
View File
@@ -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};
}
+38 -66
View File
@@ -1,80 +1,52 @@
/** import { readPdfPages } from './pdfDocumentReader.js';
* PDF'ten metin çıkarma (tarayıcı içinde, pdfjs-dist ile).
* Dinamik import edilir — ana bundle'a girmez.
*/
export async function extractPdfText(file, { maxPages = 40 } = {}) { async function openPdf(file) {
const pdfjs = await import('pdfjs-dist'); const pdfjs = await import('pdfjs-dist');
// Worker'ı Vite'ın bundle ettiği dosyadan al (CDN'e çıkmaz) pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default; let task;
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; try {
task = pdfjs.getDocument({data:await file.arrayBuffer()});
const buf = await file.arrayBuffer(); return {doc:await task.promise,task};
let doc; }
try { doc = await pdfjs.getDocument({ data: buf }).promise; } catch(error) {
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 === '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.'); 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.'); 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 async function readPdf(file,{ocr=false,forceOcr=false,maxPages=Infinity,onProgress}={}) {
export async function extractPlainText(file) { const {doc,task}=await openPdf(file);
const text = await file.text(); let worker;
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 = [];
try { try {
for (let i = 1; i <= pages; i++) { const result=await readPdfPages(doc,{maxPages,forceOcr,onProgress,ocrPage:ocr ? async(page)=>{
onProgress?.({ sayfa: i, toplam: pages }); if(!worker) {
const page = await doc.getPage(i); const {createWorker}=await import('tesseract.js');
const viewport = page.getViewport({ scale: 2 }); // ~150 dpi: OCR için yeterli, hızlı worker=await createWorker('tur');
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 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 { } 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 } = {}) { export const extractPdfText=(file,options={})=>readPdf(file,{...options,ocr:false});
const name = (file.name || '').toLowerCase(); export const ocrPdfText=(file,options={})=>readPdf(file,{...options,ocr:true,forceOcr:true});
if (name.endsWith('.pdf') || file.type === 'application/pdf') { export async function extractPlainText(file) {
const r = await extractPdfText(file); return {text:(await file.text()).trim(),pageCount:1,usedPages:1,complete:true};
// Metin katmanı yoksa taranmış PDF'tir — OCR'a düş }
if (ocr && (r.text || '').replace(/\s/g, '').length < 100) { export async function extractText(file,{ocr=true,onProgress}={}) {
try { return await ocrPdfText(file, { onProgress }); } catch { return r; } if((file.name || '').toLowerCase().endsWith('.pdf') || file.type==='application/pdf')
} return readPdf(file,{ocr,onProgress});
return r;
}
return extractPlainText(file); return extractPlainText(file);
} }
+39
View File
@@ -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(); }
});