mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-13 17:54:19 +01:00
feat: integrate Tesseract.js with improved language availability and font handling
- Refactored OCR page recognition to utilize a configured Tesseract worker. - Added functions to manage font URLs and asset filenames based on language. - Implemented language availability checks and error handling for unsupported languages. - Enhanced PDF workflow to display available OCR languages and handle user selections. - Introduced utility functions for resolving Tesseract asset configurations. - Added tests for OCR functionality, font loading, and Tesseract runtime behavior. - Updated global types to include environment variables for Tesseract and font configurations.
This commit is contained in:
1 parent
58c78b09d2
commit
77da6d7a7d
23 files changed
+1906
-564
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { createConfiguredTesseractWorker } = vi.hoisted(() => ({
|
||||
createConfiguredTesseractWorker: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorker = {
|
||||
recognize: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../js/utils/tesseract-runtime', () => ({
|
||||
createConfiguredTesseractWorker,
|
||||
}));
|
||||
|
||||
import { recognizePageCanvas } from '../../js/compare/engine/ocr-page';
|
||||
|
||||
describe('compare OCR page recognition', () => {
|
||||
beforeEach(() => {
|
||||
createConfiguredTesseractWorker.mockReset();
|
||||
mockWorker.recognize.mockReset();
|
||||
mockWorker.terminate.mockReset();
|
||||
createConfiguredTesseractWorker.mockResolvedValue(mockWorker);
|
||||
});
|
||||
|
||||
it('uses the configured Tesseract worker and maps OCR words into compare text items', async () => {
|
||||
const progress = vi.fn();
|
||||
const canvas = {
|
||||
width: 300,
|
||||
height: 150,
|
||||
} as HTMLCanvasElement;
|
||||
|
||||
mockWorker.recognize.mockResolvedValue({
|
||||
data: {
|
||||
words: [
|
||||
{
|
||||
text: 'Hello',
|
||||
bbox: { x0: 10, y0: 20, x1: 60, y1: 40 },
|
||||
},
|
||||
{
|
||||
text: 'world',
|
||||
bbox: { x0: 70, y0: 20, x1: 120, y1: 40 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const model = await recognizePageCanvas(canvas, 'eng', progress);
|
||||
|
||||
expect(createConfiguredTesseractWorker).toHaveBeenCalledWith(
|
||||
'eng',
|
||||
1,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockWorker.recognize).toHaveBeenCalledWith(canvas);
|
||||
expect(mockWorker.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(model.source).toBe('ocr');
|
||||
expect(model.hasText).toBe(true);
|
||||
expect(model.plainText).toContain('Hello');
|
||||
expect(model.textItems).toHaveLength(1);
|
||||
|
||||
const logger = createConfiguredTesseractWorker.mock
|
||||
.calls[0][2] as (message: { status: string; progress: number }) => void;
|
||||
logger({ status: 'recognizing text', progress: 0.5 });
|
||||
expect(progress).toHaveBeenCalledWith('recognizing text', 0.5);
|
||||
});
|
||||
|
||||
it('terminates the worker when compare OCR fails', async () => {
|
||||
const canvas = {
|
||||
width: 300,
|
||||
height: 150,
|
||||
} as HTMLCanvasElement;
|
||||
mockWorker.recognize.mockRejectedValueOnce(new Error('compare ocr failed'));
|
||||
|
||||
await expect(recognizePageCanvas(canvas, 'eng')).rejects.toThrow(
|
||||
'compare ocr failed'
|
||||
);
|
||||
|
||||
expect(mockWorker.terminate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getFontAssetFileName } from '../js/config/font-mappings';
|
||||
import { resolveFontUrl } from '../js/utils/font-loader';
|
||||
|
||||
describe('font-loader', () => {
|
||||
it('uses the default public font URL when no offline font base URL is configured', () => {
|
||||
expect(resolveFontUrl('Noto Sans', {})).toBe(
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a self-hosted font URL when an OCR font base URL is configured', () => {
|
||||
expect(
|
||||
resolveFontUrl('Noto Sans Arabic', {
|
||||
VITE_OCR_FONT_BASE_URL: 'https://internal.example.com/wasm/ocr/fonts/',
|
||||
})
|
||||
).toBe(
|
||||
'https://internal.example.com/wasm/ocr/fonts/NotoSansArabic-Regular.ttf'
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the bundled font asset file name from the default font URL', () => {
|
||||
expect(getFontAssetFileName('Noto Sans SC')).toBe(
|
||||
'NotoSansCJKsc-Regular.otf'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
createConfiguredTesseractWorker,
|
||||
getPDFDocument,
|
||||
getFontForLanguage,
|
||||
parseHocrDocument,
|
||||
} = vi.hoisted(() => ({
|
||||
createConfiguredTesseractWorker: vi.fn(),
|
||||
getPDFDocument: vi.fn(),
|
||||
getFontForLanguage: vi.fn(),
|
||||
parseHocrDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockWorker = {
|
||||
setParameters: vi.fn(),
|
||||
recognize: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPdfPage = {
|
||||
getViewport: vi.fn(() => ({ width: 200, height: 100 })),
|
||||
render: vi.fn(() => ({ promise: Promise.resolve() })),
|
||||
};
|
||||
|
||||
const mockPdfOutputPage = {
|
||||
drawImage: vi.fn(),
|
||||
drawText: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPdfDoc = {
|
||||
registerFontkit: vi.fn(),
|
||||
embedFont: vi.fn(async () => ({ widthOfTextAtSize: vi.fn(() => 12) })),
|
||||
addPage: vi.fn(() => mockPdfOutputPage),
|
||||
embedPng: vi.fn(async () => ({ id: 'png' })),
|
||||
save: vi.fn(async () => new Uint8Array([1, 2, 3])),
|
||||
};
|
||||
|
||||
vi.mock('../js/utils/tesseract-runtime', () => ({
|
||||
createConfiguredTesseractWorker,
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/helpers.js', () => ({
|
||||
getPDFDocument,
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/font-loader.js', () => ({
|
||||
getFontForLanguage,
|
||||
}));
|
||||
|
||||
vi.mock('../js/utils/hocr-transform.js', () => ({
|
||||
parseHocrDocument,
|
||||
calculateWordTransform: vi.fn(),
|
||||
calculateSpaceTransform: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('pdf-lib', () => ({
|
||||
PDFDocument: {
|
||||
create: vi.fn(async () => mockPdfDoc),
|
||||
},
|
||||
StandardFonts: {
|
||||
Helvetica: 'Helvetica',
|
||||
},
|
||||
rgb: vi.fn(() => ({ r: 0, g: 0, b: 0 })),
|
||||
}));
|
||||
|
||||
vi.mock('@pdf-lib/fontkit', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
import { performOcr } from '../js/utils/ocr';
|
||||
|
||||
describe('performOcr', () => {
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const originalFileReader = globalThis.FileReader;
|
||||
|
||||
beforeEach(() => {
|
||||
createConfiguredTesseractWorker.mockReset();
|
||||
getPDFDocument.mockReset();
|
||||
getFontForLanguage.mockReset();
|
||||
parseHocrDocument.mockReset();
|
||||
|
||||
mockWorker.setParameters.mockReset();
|
||||
mockWorker.recognize.mockReset();
|
||||
mockWorker.terminate.mockReset();
|
||||
mockPdfPage.getViewport.mockClear();
|
||||
mockPdfPage.render.mockClear();
|
||||
mockPdfOutputPage.drawImage.mockClear();
|
||||
mockPdfOutputPage.drawText.mockClear();
|
||||
mockPdfDoc.registerFontkit.mockClear();
|
||||
mockPdfDoc.embedFont.mockClear();
|
||||
mockPdfDoc.addPage.mockClear();
|
||||
mockPdfDoc.embedPng.mockClear();
|
||||
mockPdfDoc.save.mockClear();
|
||||
|
||||
createConfiguredTesseractWorker.mockResolvedValue(mockWorker);
|
||||
getPDFDocument.mockReturnValue({
|
||||
promise: Promise.resolve({
|
||||
numPages: 1,
|
||||
getPage: vi.fn(async () => mockPdfPage),
|
||||
}),
|
||||
});
|
||||
getFontForLanguage.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
mockWorker.recognize.mockResolvedValue({
|
||||
data: {
|
||||
text: 'Recognized text',
|
||||
hocr: '',
|
||||
},
|
||||
});
|
||||
|
||||
document.createElement = ((tagName: string) => {
|
||||
if (tagName !== 'canvas') {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => ({
|
||||
canvas: { width: 200, height: 100 },
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
|
||||
putImageData: vi.fn(),
|
||||
})),
|
||||
toBlob: vi.fn((callback: (blob: Blob) => void) => {
|
||||
callback(
|
||||
new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
|
||||
);
|
||||
}),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
}) as typeof document.createElement;
|
||||
|
||||
globalThis.FileReader = class {
|
||||
result: ArrayBuffer = new Uint8Array([1, 2, 3]).buffer;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsArrayBuffer() {
|
||||
this.onload?.();
|
||||
}
|
||||
} as unknown as typeof FileReader;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement = originalCreateElement;
|
||||
globalThis.FileReader = originalFileReader;
|
||||
});
|
||||
|
||||
it('uses the configured Tesseract worker and terminates it after OCR completes', async () => {
|
||||
const result = await performOcr(new Uint8Array([1, 2, 3]), {
|
||||
language: 'eng',
|
||||
resolution: 2,
|
||||
binarize: false,
|
||||
whitelist: '',
|
||||
});
|
||||
|
||||
expect(createConfiguredTesseractWorker).toHaveBeenCalledWith(
|
||||
'eng',
|
||||
1,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockWorker.setParameters).toHaveBeenCalledWith({
|
||||
tessjs_create_hocr: '1',
|
||||
tessedit_pageseg_mode: '3',
|
||||
});
|
||||
expect(mockWorker.recognize).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(result.fullText).toContain('Recognized text');
|
||||
expect(result.pdfBytes).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it('terminates the Tesseract worker when OCR fails', async () => {
|
||||
mockWorker.recognize.mockRejectedValueOnce(new Error('ocr failed'));
|
||||
|
||||
await expect(
|
||||
performOcr(new Uint8Array([1, 2, 3]), {
|
||||
language: 'eng',
|
||||
resolution: 2,
|
||||
binarize: false,
|
||||
whitelist: '',
|
||||
})
|
||||
).rejects.toThrow('ocr failed');
|
||||
|
||||
expect(mockWorker.terminate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { createWorker } = vi.hoisted(() => ({
|
||||
createWorker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('tesseract.js', () => ({
|
||||
default: {
|
||||
createWorker,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
buildTesseractWorkerOptions,
|
||||
createConfiguredTesseractWorker,
|
||||
getIncompleteTesseractOverrideKeys,
|
||||
hasCompleteTesseractOverrides,
|
||||
hasConfiguredTesseractOverrides,
|
||||
resolveTesseractAssetConfig,
|
||||
} from '../js/utils/tesseract-runtime';
|
||||
import {
|
||||
assertTesseractLanguagesAvailable,
|
||||
getAvailableTesseractLanguageEntries,
|
||||
getUnavailableTesseractLanguages,
|
||||
UnsupportedOcrLanguageError,
|
||||
} from '../js/utils/tesseract-language-availability';
|
||||
|
||||
describe('tesseract-runtime', () => {
|
||||
beforeEach(() => {
|
||||
createWorker.mockReset();
|
||||
});
|
||||
|
||||
it('normalizes self-hosted OCR asset URLs', () => {
|
||||
const config = resolveTesseractAssetConfig({
|
||||
VITE_TESSERACT_WORKER_URL:
|
||||
'https://internal.example.com/ocr/worker.min.js/',
|
||||
VITE_TESSERACT_CORE_URL: 'https://internal.example.com/ocr/core/',
|
||||
VITE_TESSERACT_LANG_URL: 'https://internal.example.com/ocr/lang-data/',
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
workerPath: 'https://internal.example.com/ocr/worker.min.js',
|
||||
corePath: 'https://internal.example.com/ocr/core',
|
||||
langPath: 'https://internal.example.com/ocr/lang-data',
|
||||
});
|
||||
expect(hasConfiguredTesseractOverrides(config)).toBe(true);
|
||||
expect(hasCompleteTesseractOverrides(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns logger-only options when no self-hosted OCR assets are configured', () => {
|
||||
const logger = vi.fn();
|
||||
|
||||
expect(buildTesseractWorkerOptions(logger, {})).toEqual({ logger });
|
||||
expect(
|
||||
hasConfiguredTesseractOverrides(resolveTesseractAssetConfig({}))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('throws on partial OCR asset configuration', () => {
|
||||
const env = {
|
||||
VITE_TESSERACT_WORKER_URL:
|
||||
'https://internal.example.com/ocr/worker.min.js',
|
||||
VITE_TESSERACT_CORE_URL: 'https://internal.example.com/ocr/core',
|
||||
};
|
||||
|
||||
expect(
|
||||
getIncompleteTesseractOverrideKeys(resolveTesseractAssetConfig(env))
|
||||
).toEqual(['VITE_TESSERACT_LANG_URL']);
|
||||
expect(() => buildTesseractWorkerOptions(undefined, env)).toThrow(
|
||||
'Self-hosted OCR assets are partially configured'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes configured OCR asset URLs to Tesseract.createWorker', async () => {
|
||||
const logger = vi.fn();
|
||||
createWorker.mockResolvedValue({ id: 'worker' });
|
||||
|
||||
await createConfiguredTesseractWorker('eng', 1, logger, {
|
||||
VITE_TESSERACT_WORKER_URL:
|
||||
'https://internal.example.com/ocr/worker.min.js',
|
||||
VITE_TESSERACT_CORE_URL: 'https://internal.example.com/ocr/core',
|
||||
VITE_TESSERACT_LANG_URL: 'https://internal.example.com/ocr/lang-data',
|
||||
});
|
||||
|
||||
expect(createWorker).toHaveBeenCalledWith('eng', 1, {
|
||||
logger,
|
||||
workerPath: 'https://internal.example.com/ocr/worker.min.js',
|
||||
corePath: 'https://internal.example.com/ocr/core',
|
||||
langPath: 'https://internal.example.com/ocr/lang-data',
|
||||
gzip: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters OCR language entries when the build restricts bundled languages', () => {
|
||||
expect(
|
||||
getAvailableTesseractLanguageEntries({
|
||||
VITE_TESSERACT_AVAILABLE_LANGUAGES: 'eng,deu',
|
||||
})
|
||||
).toEqual([
|
||||
['eng', 'English'],
|
||||
['deu', 'German'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports unavailable OCR languages for restricted air-gap builds', () => {
|
||||
expect(
|
||||
getUnavailableTesseractLanguages('eng+fra', {
|
||||
VITE_TESSERACT_AVAILABLE_LANGUAGES: 'eng,deu',
|
||||
})
|
||||
).toEqual(['fra']);
|
||||
|
||||
expect(() =>
|
||||
assertTesseractLanguagesAvailable('eng+fra', {
|
||||
VITE_TESSERACT_AVAILABLE_LANGUAGES: 'eng,deu',
|
||||
})
|
||||
).toThrow(UnsupportedOcrLanguageError);
|
||||
});
|
||||
|
||||
it('blocks worker creation when OCR requests an unbundled language', async () => {
|
||||
await expect(
|
||||
createConfiguredTesseractWorker('fra', 1, undefined, {
|
||||
VITE_TESSERACT_AVAILABLE_LANGUAGES: 'eng,deu',
|
||||
})
|
||||
).rejects.toThrow('This BentoPDF build only bundles OCR data for');
|
||||
|
||||
expect(createWorker).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user