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:
alam00000 committed 2026-03-14 15:50:30 +05:30
1 parent 58c78b09d2
commit 77da6d7a7d
23 files changed
+1906 -564

No files matched your search

+81
View File
@@ -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);
});
});
+28
View File
@@ -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'
);
});
});
+185
View File
@@ -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);
});
});
+128
View File
@@ -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();
});
});