mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-11 09:23:36 +01:00
fix(ocr): Arabic+Latin font fix and added tests
This commit is contained in:
1 parent
58a0053778
commit
0ce043c18e
8 files changed
+356
-165
No files matched your search
@@ -7,8 +7,9 @@ import {
|
||||
Baseline,
|
||||
} from '@/types';
|
||||
|
||||
const BBOX_PATTERN = /bbox\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/;
|
||||
const BASELINE_PATTERN = /baseline\s+([-+]?\d*\.?\d*)\s+([-+]?\d+)/;
|
||||
const BBOX_PATTERN =
|
||||
/bbox\s+(\d+\.?\d*)\s+(\d+\.?\d*)\s+(\d+\.?\d*)\s+(\d+\.?\d*)/;
|
||||
const BASELINE_PATTERN = /baseline\s+([-+]?\d*\.?\d*)\s+([-+]?\d+\.?\d*)/;
|
||||
const TEXTANGLE_PATTERN = /textangle\s+([-+]?\d*\.?\d*)/;
|
||||
|
||||
export function parseBBox(title: string): BBox | null {
|
||||
@@ -16,10 +17,10 @@ export function parseBBox(title: string): BBox | null {
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
x0: parseInt(match[1], 10),
|
||||
y0: parseInt(match[2], 10),
|
||||
x1: parseInt(match[3], 10),
|
||||
y1: parseInt(match[4], 10),
|
||||
x0: parseFloat(match[1]),
|
||||
y0: parseFloat(match[2]),
|
||||
x1: parseFloat(match[3]),
|
||||
y1: parseFloat(match[4]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ export function parseBaseline(title: string): Baseline {
|
||||
|
||||
return {
|
||||
slope: parseFloat(match[1]) || 0,
|
||||
intercept: parseInt(match[2], 10) || 0,
|
||||
intercept: parseFloat(match[2]) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +92,7 @@ export function parseHocrDocument(hocrText: string): OcrPage {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const wordElements = doc.querySelectorAll('.ocrx_word');
|
||||
const wordElements = Array.from(doc.querySelectorAll('.ocrx_word'));
|
||||
if (wordElements.length > 0) {
|
||||
const words = parseWordsFromElements(wordElements);
|
||||
if (words.length > 0) {
|
||||
@@ -123,7 +124,7 @@ function parseHocrLine(lineElement: Element): OcrLine | null {
|
||||
const parent = lineElement.closest('.ocr_par') || lineElement.parentElement;
|
||||
const direction = parent ? getTextDirection(parent) : 'ltr';
|
||||
const injectWordBreaks = parent ? shouldInjectWordBreaks(parent) : true;
|
||||
const wordElements = lineElement.querySelectorAll('.ocrx_word');
|
||||
const wordElements = Array.from(lineElement.querySelectorAll('.ocrx_word'));
|
||||
const words = parseWordsFromElements(wordElements);
|
||||
|
||||
return {
|
||||
@@ -136,7 +137,7 @@ function parseHocrLine(lineElement: Element): OcrLine | null {
|
||||
};
|
||||
}
|
||||
|
||||
function parseWordsFromElements(wordElements: NodeListOf<Element>): OcrWord[] {
|
||||
function parseWordsFromElements(wordElements: Element[]): OcrWord[] {
|
||||
const words: OcrWord[] = [];
|
||||
|
||||
wordElements.forEach((wordEl) => {
|
||||
@@ -174,6 +175,44 @@ function calculateBoundingBox(bboxes: BBox[]): BBox {
|
||||
};
|
||||
}
|
||||
|
||||
function scaleBBox(bbox: BBox, sx: number, sy: number): BBox {
|
||||
return {
|
||||
x0: bbox.x0 * sx,
|
||||
y0: bbox.y0 * sy,
|
||||
x1: bbox.x1 * sx,
|
||||
y1: bbox.y1 * sy,
|
||||
};
|
||||
}
|
||||
|
||||
export function scaleOcrPageToPdfPoints(
|
||||
ocrPage: OcrPage,
|
||||
pdfWidth: number,
|
||||
pdfHeight: number
|
||||
): void {
|
||||
if (ocrPage.width <= 0 || ocrPage.height <= 0) return;
|
||||
const scaleX = pdfWidth / ocrPage.width;
|
||||
const scaleY = pdfHeight / ocrPage.height;
|
||||
if (scaleX === 1 && scaleY === 1) return;
|
||||
|
||||
for (const line of ocrPage.lines) {
|
||||
line.bbox = scaleBBox(line.bbox, scaleX, scaleY);
|
||||
line.baseline = {
|
||||
slope: line.baseline.slope,
|
||||
intercept: line.baseline.intercept * scaleY,
|
||||
};
|
||||
for (const word of line.words) {
|
||||
word.bbox = scaleBBox(word.bbox, scaleX, scaleY);
|
||||
}
|
||||
}
|
||||
ocrPage.width = pdfWidth;
|
||||
ocrPage.height = pdfHeight;
|
||||
}
|
||||
|
||||
export function calculateLineFontSize(line: OcrLine): number {
|
||||
const lineHeight = line.bbox.y1 - line.bbox.y0;
|
||||
return Math.max(lineHeight + line.baseline.intercept, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the transformation parameters for drawing a word
|
||||
*
|
||||
@@ -222,8 +261,6 @@ export function calculateWordTransform(
|
||||
const rotation = -line.textangle + slopeAngle;
|
||||
|
||||
const x = wordBBox.x0;
|
||||
|
||||
// pdf-lib draws text from baseline, so we position at word bottom
|
||||
const y = pageHeight - wordBBox.y1;
|
||||
|
||||
return {
|
||||
|
||||
+215
-122
@@ -1,12 +1,14 @@
|
||||
import Tesseract from 'tesseract.js';
|
||||
import { PDFDocument, StandardFonts, rgb, PDFFont } from 'pdf-lib';
|
||||
import { PDFDocument, StandardFonts, rgb, PDFFont, degrees } from 'pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { getFontForLanguage } from './font-loader.js';
|
||||
import { languageToFontFamily } from '../config/font-mappings.js';
|
||||
import { OcrPage, OcrLine } from '@/types';
|
||||
import {
|
||||
parseHocrDocument,
|
||||
calculateWordTransform,
|
||||
calculateSpaceTransform,
|
||||
scaleOcrPageToPdfPoints,
|
||||
} from './hocr-transform.js';
|
||||
import { getPDFDocument } from './helpers.js';
|
||||
import { loadPdfDocument } from './load-pdf-document.js';
|
||||
@@ -18,13 +20,22 @@ export interface OcrOptions {
|
||||
binarize: boolean;
|
||||
whitelist: string;
|
||||
embedFullFonts?: boolean;
|
||||
psm?: Tesseract.PSM;
|
||||
perPageTimeoutMs?: number;
|
||||
onProgress?: (status: string, progress: number) => void;
|
||||
}
|
||||
|
||||
export interface OcrWarning {
|
||||
page: number;
|
||||
kind: 'recognize-timeout' | 'recognize-error' | 'draw-error' | 'page-error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface OcrResult {
|
||||
pdfBytes: Uint8Array;
|
||||
pdfDoc: PDFDocument;
|
||||
fullText: string;
|
||||
warnings: OcrWarning[];
|
||||
}
|
||||
|
||||
function binarizeCanvas(ctx: CanvasRenderingContext2D) {
|
||||
@@ -45,24 +56,27 @@ function drawOcrTextLayer(
|
||||
pageHeight: number,
|
||||
primaryFont: PDFFont,
|
||||
latinFont: PDFFont
|
||||
): void {
|
||||
): { droppedWords: number; droppedSpaces: number } {
|
||||
let droppedWords = 0;
|
||||
let droppedSpaces = 0;
|
||||
ocrPage.lines.forEach(function (line: OcrLine) {
|
||||
const words = line.words;
|
||||
const words =
|
||||
line.direction === 'rtl' ? [...line.words].reverse() : line.words;
|
||||
const lineRotation = degrees(
|
||||
-line.textangle + Math.atan(line.baseline.slope) * (180 / Math.PI)
|
||||
);
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
const text = word.text.replace(
|
||||
/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g,
|
||||
''
|
||||
);
|
||||
const text = word.text.replace(/[\p{Cc}\p{Cf}]/gu, '');
|
||||
|
||||
if (!text.trim()) continue;
|
||||
|
||||
const hasNonLatin = /[^\u0000-\u007F]/.test(text);
|
||||
const hasNonLatin = /[^\p{ASCII}]/u.test(text);
|
||||
const font = hasNonLatin ? primaryFont : latinFont;
|
||||
|
||||
if (!font) {
|
||||
console.warn('Font not available for text: "' + text + '"');
|
||||
droppedWords++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -87,11 +101,12 @@ function drawOcrTextLayer(
|
||||
y: transform.y,
|
||||
font,
|
||||
size: transform.fontSize,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0,
|
||||
color: rgb(1, 0, 0),
|
||||
opacity: 1,
|
||||
rotate: lineRotation,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Could not draw text "${text}":`, error);
|
||||
} catch {
|
||||
droppedWords++;
|
||||
}
|
||||
|
||||
if (line.injectWordBreaks && i < words.length - 1) {
|
||||
@@ -117,16 +132,43 @@ function drawOcrTextLayer(
|
||||
y: spaceTransform.y,
|
||||
font,
|
||||
size: spaceTransform.fontSize,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0,
|
||||
color: rgb(1, 0, 0),
|
||||
opacity: 1,
|
||||
rotate: lineRotation,
|
||||
});
|
||||
} catch {
|
||||
console.warn(`Could not draw space between words`);
|
||||
droppedSpaces++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return { droppedWords, droppedSpaces };
|
||||
}
|
||||
|
||||
const DEFAULT_PER_PAGE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const PDF_CLEANUP_INTERVAL_PAGES = 25;
|
||||
|
||||
function recognizeWithTimeout(
|
||||
worker: Tesseract.Worker,
|
||||
canvas: HTMLCanvasElement,
|
||||
timeoutMs: number
|
||||
): Promise<Tesseract.RecognizeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`OCR recognize timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
worker
|
||||
.recognize(canvas, {}, { text: true, hocr: true })
|
||||
.then((result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function performOcr(
|
||||
@@ -139,9 +181,13 @@ export async function performOcr(
|
||||
binarize,
|
||||
whitelist,
|
||||
embedFullFonts,
|
||||
psm,
|
||||
perPageTimeoutMs,
|
||||
onProgress,
|
||||
} = options;
|
||||
const progress = onProgress || (() => {});
|
||||
const pageTimeoutMs = perPageTimeoutMs ?? DEFAULT_PER_PAGE_TIMEOUT_MS;
|
||||
const warnings: OcrWarning[] = [];
|
||||
|
||||
const worker = await createConfiguredTesseractWorker(
|
||||
language,
|
||||
@@ -151,140 +197,187 @@ export async function performOcr(
|
||||
}
|
||||
);
|
||||
|
||||
await worker.setParameters({
|
||||
tessjs_create_hocr: '1',
|
||||
tessedit_pageseg_mode: Tesseract.PSM.AUTO,
|
||||
});
|
||||
|
||||
if (whitelist) {
|
||||
await worker.setParameters({
|
||||
tessedit_char_whitelist: whitelist,
|
||||
});
|
||||
}
|
||||
|
||||
const sourcePdfDoc = await loadPdfDocument(pdfBytes);
|
||||
const pdf = await getPDFDocument({ data: pdfBytes }).promise;
|
||||
const newPdfDoc = await PDFDocument.create();
|
||||
|
||||
newPdfDoc.registerFontkit(fontkit);
|
||||
|
||||
progress('Loading fonts...', 0);
|
||||
|
||||
const selectedLangs = language.split('+');
|
||||
const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
|
||||
const indicLangs = [
|
||||
'hin',
|
||||
'ben',
|
||||
'guj',
|
||||
'kan',
|
||||
'mal',
|
||||
'ori',
|
||||
'pan',
|
||||
'tam',
|
||||
'tel',
|
||||
'sin',
|
||||
];
|
||||
const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr'];
|
||||
|
||||
const primaryLang =
|
||||
selectedLangs.find((l) => priorityLangs.includes(l)) ||
|
||||
selectedLangs[0] ||
|
||||
'eng';
|
||||
|
||||
const hasCJK = selectedLangs.some((l) => cjkLangs.includes(l));
|
||||
const hasIndic = selectedLangs.some((l) => indicLangs.includes(l));
|
||||
const hasLatin =
|
||||
selectedLangs.some((l) => !priorityLangs.includes(l)) ||
|
||||
selectedLangs.includes('eng');
|
||||
const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK;
|
||||
|
||||
let primaryFont: PDFFont;
|
||||
let latinFont: PDFFont;
|
||||
let fullText = '';
|
||||
let newPdfDoc: PDFDocument | undefined;
|
||||
|
||||
try {
|
||||
if (isIndicPlusLatin) {
|
||||
const [scriptFontBytes, latinFontBytes] = await Promise.all([
|
||||
getFontForLanguage(primaryLang),
|
||||
getFontForLanguage('eng'),
|
||||
]);
|
||||
primaryFont = await newPdfDoc.embedFont(scriptFontBytes, {
|
||||
subset: !embedFullFonts,
|
||||
});
|
||||
latinFont = await newPdfDoc.embedFont(latinFontBytes, {
|
||||
subset: !embedFullFonts,
|
||||
});
|
||||
} else {
|
||||
const fontBytes = await getFontForLanguage(primaryLang);
|
||||
primaryFont = await newPdfDoc.embedFont(fontBytes, {
|
||||
subset: !embedFullFonts,
|
||||
await worker.setParameters({
|
||||
tessedit_pageseg_mode: psm ?? Tesseract.PSM.AUTO,
|
||||
});
|
||||
|
||||
if (whitelist) {
|
||||
await worker.setParameters({
|
||||
tessedit_char_whitelist: whitelist,
|
||||
});
|
||||
}
|
||||
|
||||
const sourcePdfDoc = await loadPdfDocument(pdfBytes);
|
||||
const pdf = await getPDFDocument({ data: pdfBytes }).promise;
|
||||
newPdfDoc = await PDFDocument.create();
|
||||
|
||||
newPdfDoc.registerFontkit(fontkit);
|
||||
|
||||
progress('Loading fonts...', 0);
|
||||
|
||||
const selectedLangs = language.split('+');
|
||||
const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
|
||||
const indicLangs = [
|
||||
'hin',
|
||||
'ben',
|
||||
'guj',
|
||||
'kan',
|
||||
'mal',
|
||||
'ori',
|
||||
'pan',
|
||||
'tam',
|
||||
'tel',
|
||||
'sin',
|
||||
];
|
||||
const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr'];
|
||||
|
||||
const primaryLang =
|
||||
selectedLangs.find((l) => priorityLangs.includes(l)) ||
|
||||
selectedLangs[0] ||
|
||||
'eng';
|
||||
|
||||
const hasCJK = selectedLangs.some((l) => cjkLangs.includes(l));
|
||||
const hasLatin =
|
||||
selectedLangs.some((l) => !priorityLangs.includes(l)) ||
|
||||
selectedLangs.includes('eng');
|
||||
const primaryFontFamily = languageToFontFamily[primaryLang] ?? 'Noto Sans';
|
||||
const latinFontFamily = languageToFontFamily['eng'] ?? 'Noto Sans';
|
||||
const sharesLatinFamily = primaryFontFamily === latinFontFamily;
|
||||
const needsSeparateLatinFont =
|
||||
priorityLangs.includes(primaryLang) &&
|
||||
hasLatin &&
|
||||
!hasCJK &&
|
||||
!sharesLatinFamily;
|
||||
|
||||
let primaryFont: PDFFont;
|
||||
let latinFont: PDFFont;
|
||||
|
||||
try {
|
||||
if (needsSeparateLatinFont) {
|
||||
const [scriptFontBytes, latinFontBytes] = await Promise.all([
|
||||
getFontForLanguage(primaryLang),
|
||||
getFontForLanguage('eng'),
|
||||
]);
|
||||
primaryFont = await newPdfDoc.embedFont(scriptFontBytes, {
|
||||
subset: !embedFullFonts,
|
||||
});
|
||||
latinFont = await newPdfDoc.embedFont(latinFontBytes, {
|
||||
subset: !embedFullFonts,
|
||||
});
|
||||
} else {
|
||||
const fontBytes = await getFontForLanguage(primaryLang);
|
||||
primaryFont = await newPdfDoc.embedFont(fontBytes, {
|
||||
subset: !embedFullFonts,
|
||||
});
|
||||
latinFont = primaryFont;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Font loading failed, falling back to Helvetica', e);
|
||||
primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
latinFont = primaryFont;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Font loading failed, falling back to Helvetica', e);
|
||||
primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
latinFont = primaryFont;
|
||||
}
|
||||
|
||||
let fullText = '';
|
||||
|
||||
try {
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
progress(
|
||||
`Processing page ${i} of ${pdf.numPages}`,
|
||||
(i - 1) / pdf.numPages
|
||||
);
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: resolution });
|
||||
const pdfJsPage = await pdf.getPage(i);
|
||||
try {
|
||||
const viewport = pdfJsPage.getViewport({ scale: resolution });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Failed to create canvas context');
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Failed to create canvas context');
|
||||
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
await pdfJsPage.render({ canvasContext: context, viewport, canvas })
|
||||
.promise;
|
||||
|
||||
if (binarize) {
|
||||
binarizeCanvas(context);
|
||||
if (binarize) {
|
||||
binarizeCanvas(context);
|
||||
}
|
||||
|
||||
let pageText = '';
|
||||
let pageHocr = '';
|
||||
try {
|
||||
const result = await recognizeWithTimeout(
|
||||
worker,
|
||||
canvas,
|
||||
pageTimeoutMs
|
||||
);
|
||||
pageText = result.data.text ?? '';
|
||||
pageHocr = result.data.hocr ?? '';
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
warnings.push({
|
||||
page: i,
|
||||
kind: /timed out/i.test(message)
|
||||
? 'recognize-timeout'
|
||||
: 'recognize-error',
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
const [copiedPage] = await newPdfDoc.copyPages(sourcePdfDoc, [i - 1]);
|
||||
newPdfDoc.addPage(copiedPage);
|
||||
|
||||
if (pageHocr) {
|
||||
const ocrPage = parseHocrDocument(pageHocr);
|
||||
const pdfPageWidth = copiedPage.getWidth();
|
||||
const pdfPageHeight = copiedPage.getHeight();
|
||||
scaleOcrPageToPdfPoints(ocrPage, pdfPageWidth, pdfPageHeight);
|
||||
const drawStats = drawOcrTextLayer(
|
||||
copiedPage,
|
||||
ocrPage,
|
||||
pdfPageHeight,
|
||||
primaryFont,
|
||||
latinFont
|
||||
);
|
||||
if (drawStats.droppedWords > 0 || drawStats.droppedSpaces > 0) {
|
||||
warnings.push({
|
||||
page: i,
|
||||
kind: 'draw-error',
|
||||
message: `${drawStats.droppedWords} word(s) and ${drawStats.droppedSpaces} space(s) failed to render in the text layer`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fullText += pageText + '\n\n';
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
warnings.push({ page: i, kind: 'page-error', message });
|
||||
} finally {
|
||||
pdfJsPage.cleanup();
|
||||
}
|
||||
|
||||
const result = await worker.recognize(
|
||||
canvas,
|
||||
{},
|
||||
{ text: true, hocr: true }
|
||||
);
|
||||
const data = result.data;
|
||||
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
const [copiedPage] = await newPdfDoc.copyPages(sourcePdfDoc, [i - 1]);
|
||||
newPdfDoc.addPage(copiedPage);
|
||||
|
||||
if (data.hocr) {
|
||||
const ocrPage = parseHocrDocument(data.hocr);
|
||||
drawOcrTextLayer(
|
||||
copiedPage,
|
||||
ocrPage,
|
||||
viewport.height,
|
||||
primaryFont,
|
||||
latinFont
|
||||
);
|
||||
if (i % PDF_CLEANUP_INTERVAL_PAGES === 0) {
|
||||
pdf.cleanup();
|
||||
}
|
||||
|
||||
fullText += data.text + '\n\n';
|
||||
}
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
|
||||
if (!newPdfDoc) {
|
||||
throw new Error('OCR aborted before any page was processed');
|
||||
}
|
||||
|
||||
const savedBytes = await newPdfDoc.save();
|
||||
|
||||
return {
|
||||
pdfBytes: new Uint8Array(savedBytes),
|
||||
pdfDoc: newPdfDoc,
|
||||
fullText,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user