Files
Trilium/apps/server/src/services/ocr/ocr_service.ts
T
2026-04-02 20:26:31 +03:00

577 lines
19 KiB
TypeScript

import { getTesseractCode } from '@triliumnext/commons';
import becca from '../../becca/becca.js';
import blobService from '../blob.js';
import entityChangesService from '../entity_changes.js';
import log from '../log.js';
import options from '../options.js';
import sql from '../sql.js';
import { FileProcessor } from './processors/file_processor.js';
import { ImageProcessor } from './processors/image_processor.js';
import { OfficeProcessor } from './processors/office_processor.js';
import { PDFProcessor } from './processors/pdf_processor.js';
import { TIFFProcessor } from './processors/tiff_processor.js';
export interface OCRResult {
text: string;
confidence: number;
extractedAt: string;
language?: string;
pageCount?: number;
}
export interface OCRProcessingOptions {
language?: string;
forceReprocess?: boolean;
confidence?: number;
enablePDFTextExtraction?: boolean;
}
interface OCRBlobRow {
blobId: string;
textRepresentation: string;
}
/**
* OCR Service for extracting text from images and other OCR-able objects
* Uses Tesseract.js for text recognition
*/
class OCRService {
private isProcessing = false;
private processors: Map<string, FileProcessor> = new Map();
constructor() {
const imageProcessor = new ImageProcessor();
this.processors.set('image', imageProcessor);
this.processors.set('pdf', new PDFProcessor());
this.processors.set('tiff', new TIFFProcessor(imageProcessor));
this.processors.set('office', new OfficeProcessor());
}
/**
* Resolves the Tesseract language code(s) for OCR processing.
*
* Priority:
* 1. Explicitly passed `language` option (e.g. from API call)
* 2. The note's `language` label (mapped via {@link getTesseractCode})
* 3. All enabled content languages joined with `+`
* 4. The UI locale
* 5. Fallback to `eng`
*/
resolveOcrLanguage(noteId?: string, explicitLanguage?: string): string {
// 1. Explicit language from caller
if (explicitLanguage) {
return explicitLanguage;
}
// 2. Note's language label
if (noteId) {
const note = becca.getNote(noteId);
const noteLanguage = note?.getLabelValue("language");
if (noteLanguage) {
const code = getTesseractCode(noteLanguage);
if (code) {
return code;
}
}
}
// 3. All enabled content languages
try {
const languagesJson = options.getOption("languages");
const enabledLanguages = JSON.parse(languagesJson || "[]") as string[];
if (enabledLanguages.length > 0) {
const codes = enabledLanguages
.map((id) => getTesseractCode(id))
.filter((code): code is string => code !== null);
// Deduplicate (e.g. en + en-GB both map to eng)
const unique = [...new Set(codes)];
if (unique.length > 0) {
return unique.join("+");
}
}
} catch {
// Fall through
}
// 4. UI locale
try {
const uiLocale = options.getOption("locale");
if (uiLocale) {
const code = getTesseractCode(uiLocale);
if (code) {
return code;
}
}
} catch {
// Fall through
}
// 5. Fallback
return "eng";
}
/**
* Extract text from file buffer using appropriate processor
*/
async extractTextFromFile(fileBuffer: Buffer, mimeType: string, options: OCRProcessingOptions = {}): Promise<OCRResult> {
try {
log.info(`Starting OCR text extraction for MIME type: ${mimeType} with language: ${options.language || "eng"}`);
this.isProcessing = true;
// Find appropriate processor
const processor = this.getProcessorForMimeType(mimeType);
if (!processor) {
throw new Error(`No processor found for MIME type: ${mimeType}`);
}
const result = await processor.extractText(fileBuffer, options);
log.info(`OCR extraction completed. Confidence: ${Math.round(result.confidence * 100)}%, Text length: ${result.text.length}`);
return result;
} catch (error) {
log.error(`OCR text extraction failed: ${error}`);
throw error;
} finally {
this.isProcessing = false;
}
}
/**
* Process OCR for a note (image type)
*/
async processNoteOCR(noteId: string, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
const note = becca.getNote(noteId);
if (!note) {
log.error(`Note ${noteId} not found`);
return null;
}
return this.processEntityOCR({
entityId: noteId,
entityType: 'note',
category: note.type,
mime: note.mime,
blobId: note.blobId,
languageNoteId: noteId,
getContent: () => note.getContent()
}, options);
}
/**
* Process OCR for an attachment
*/
async processAttachmentOCR(attachmentId: string, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
const attachment = becca.getAttachment(attachmentId);
if (!attachment) {
log.error(`Attachment ${attachmentId} not found`);
return null;
}
return this.processEntityOCR({
entityId: attachmentId,
entityType: 'attachment',
category: attachment.role,
mime: attachment.mime,
blobId: attachment.blobId,
languageNoteId: attachment.ownerId,
getContent: () => attachment.getContent()
}, options);
}
/**
* Shared OCR processing logic for both notes and attachments.
*/
private async processEntityOCR(entity: {
entityId: string;
entityType: string;
category: string;
mime: string;
blobId: string | undefined;
languageNoteId: string;
getContent: () => string | Buffer;
}, options: OCRProcessingOptions = {}): Promise<OCRResult | null> {
const { entityId, entityType, category, mime, blobId, languageNoteId } = entity;
if (!['image', 'file'].includes(category)) {
log.info(`${entityType} ${entityId} is not an image or file, skipping OCR`);
return null;
}
if (!this.getProcessorForMimeType(mime)) {
log.info(`${entityType} ${entityId} has unsupported MIME type ${mime} for text extraction, skipping`);
return null;
}
if (!options.forceReprocess && this.hasStoredOCRResult(blobId)) {
log.info(`OCR already exists for ${entityType} ${entityId}, skipping`);
return null;
}
try {
const content = entity.getContent();
if (!content || !(content instanceof Buffer)) {
throw new Error(`Cannot get content for ${entityType} ${entityId}`);
}
const language = this.resolveOcrLanguage(languageNoteId, options.language);
const ocrResult = await this.extractTextFromFile(content, mime, { ...options, language });
await this.storeOCRResult(blobId, ocrResult);
return ocrResult;
} catch (error) {
log.error(`Failed to process OCR for ${entityType} ${entityId}: ${error}`);
throw error;
}
}
/**
* Store OCR result in blob
*/
async storeOCRResult(blobId: string | undefined, ocrResult: OCRResult): Promise<void> {
if (!blobId) {
log.error('Cannot store OCR result: blobId is undefined');
return;
}
try {
sql.execute(`
UPDATE blobs SET textRepresentation = ?
WHERE blobId = ?
`, [ocrResult.text, blobId]);
this.putBlobEntityChange(blobId);
log.info(`Stored OCR result for blob ${blobId}`);
} catch (error) {
log.error(`Failed to store OCR result for blob ${blobId}: ${error}`);
throw error;
}
}
/**
* Check whether a blob already has a stored text representation.
*/
private hasStoredOCRResult(blobId: string | undefined): boolean {
if (!blobId) {
return false;
}
const row = sql.getRow<{ textRepresentation: string | null }>(
`SELECT textRepresentation FROM blobs WHERE blobId = ?`,
[blobId]
);
return !!row?.textRepresentation;
}
/**
* Search for text in OCR results
*/
searchOCRResults(searchText: string): Array<{ blobId: string; text: string }> {
try {
const query = `
SELECT blobId, textRepresentation
FROM blobs
WHERE textRepresentation LIKE ?
AND textRepresentation IS NOT NULL
`;
const params = [`%${searchText}%`];
const rows = sql.getRows<OCRBlobRow>(query, params);
return rows.map(row => ({
blobId: row.blobId,
text: row.textRepresentation
}));
} catch (error) {
log.error(`Failed to search OCR results: ${error}`);
return [];
}
}
/**
* Delete OCR results for a blob
*/
deleteOCRResult(blobId: string): void {
try {
sql.execute(`UPDATE blobs SET textRepresentation = NULL WHERE blobId = ?`, [blobId]);
this.putBlobEntityChange(blobId);
log.info(`Deleted OCR result for blob ${blobId}`);
} catch (error) {
log.error(`Failed to delete OCR result for blob ${blobId}: ${error}`);
throw error;
}
}
/**
* Get OCR statistics
*/
getOCRStats(): { totalProcessed: number; imageNotes: number; imageAttachments: number } {
try {
const stats = sql.getRow<{
total_processed: number;
}>(`
SELECT COUNT(*) as total_processed
FROM blobs
WHERE textRepresentation IS NOT NULL AND textRepresentation != ''
`);
// Count image notes with OCR
const noteStats = sql.getRow<{
count: number;
}>(`
SELECT COUNT(*) as count
FROM notes n
JOIN blobs b ON n.blobId = b.blobId
WHERE n.type = 'image'
AND n.isDeleted = 0
AND b.textRepresentation IS NOT NULL AND b.textRepresentation != ''
`);
// Count image attachments with OCR
const attachmentStats = sql.getRow<{
count: number;
}>(`
SELECT COUNT(*) as count
FROM attachments a
JOIN blobs b ON a.blobId = b.blobId
WHERE a.role = 'image'
AND a.isDeleted = 0
AND b.textRepresentation IS NOT NULL AND b.textRepresentation != ''
`);
return {
totalProcessed: stats?.total_processed || 0,
imageNotes: noteStats?.count || 0,
imageAttachments: attachmentStats?.count || 0
};
} catch (error) {
log.error(`Failed to get OCR stats: ${error}`);
return { totalProcessed: 0, imageNotes: 0, imageAttachments: 0 };
}
}
/**
* Check if currently processing
*/
isCurrentlyProcessing(): boolean {
return this.isProcessing;
}
// Batch processing state
private batchProcessingState: {
inProgress: boolean;
total: number;
processed: number;
startTime?: Date;
} = {
inProgress: false,
total: 0,
processed: 0
};
/**
* Start batch OCR processing with progress tracking
*/
async startBatchProcessing(): Promise<{ success: boolean; message?: string }> {
if (this.batchProcessingState.inProgress) {
return { success: false, message: 'Batch processing already in progress' };
}
try {
const blobsNeedingOCR = this.getBlobsNeedingOCR();
if (blobsNeedingOCR.length === 0) {
return { success: false, message: 'No images found that need OCR processing' };
}
this.batchProcessingState = {
inProgress: true,
total: blobsNeedingOCR.length,
processed: 0,
startTime: new Date()
};
// Start processing in background
this.processBlobs(blobsNeedingOCR).catch(error => {
log.error(`Batch processing failed: ${error instanceof Error ? error.message : String(error)}`);
}).finally(() => {
this.batchProcessingState.inProgress = false;
});
return { success: true };
} catch (error) {
log.error(`Failed to start batch processing: ${error instanceof Error ? error.message : String(error)}`);
return { success: false, message: error instanceof Error ? error.message : String(error) };
}
}
/**
* Get batch processing progress
*/
getBatchProgress(): { inProgress: boolean; total: number; processed: number; percentage?: number; startTime?: Date } {
const result: { inProgress: boolean; total: number; processed: number; percentage?: number; startTime?: Date } = { ...this.batchProcessingState };
if (result.total > 0) {
result.percentage = (result.processed / result.total) * 100;
}
return result;
}
/**
* Cancel batch processing
*/
cancelBatchProcessing(): void {
if (this.batchProcessingState.inProgress) {
this.batchProcessingState.inProgress = false;
log.info('Batch OCR processing cancelled');
}
}
/**
* Process a list of blobs sequentially, updating batch progress.
*/
private async processBlobs(blobs: Array<{ entityType: 'note' | 'attachment'; entityId: string }>): Promise<void> {
log.info(`Starting batch OCR processing of ${blobs.length} items...`);
for (const blob of blobs) {
if (!this.batchProcessingState.inProgress) {
break;
}
try {
await this.processOcrEntity(blob);
} catch (error) {
log.error(`Failed to process OCR for ${blob.entityType} ${blob.entityId}: ${error}`);
}
this.batchProcessingState.processed++;
// Small delay to prevent overwhelming the system
await new Promise(resolve => setTimeout(resolve, 500));
}
log.info(`Batch OCR processing completed. Processed ${this.batchProcessingState.processed} files.`);
}
/**
* Process OCR for a single entity (note or attachment) by type.
*/
private async processOcrEntity(entity: { entityType: 'note' | 'attachment'; entityId: string }): Promise<void> {
if (entity.entityType === 'note') {
await this.processNoteOCR(entity.entityId);
} else {
await this.processAttachmentOCR(entity.entityId);
}
}
/**
* Get processor for a given MIME type
*/
/**
* Notifies the sync system that a blob has changed, without modifying the blob's identity.
*/
private putBlobEntityChange(blobId: string): void {
const blob = becca.getBlob({ blobId });
if (!blob || !blob.blobId) return;
const hash = blobService.calculateContentHash({
blobId: blob.blobId,
content: blob.content,
textRepresentation: blob.textRepresentation,
utcDateModified: blob.utcDateModified!
});
entityChangesService.putEntityChange({
entityName: "blobs",
entityId: blobId,
hash,
isErased: false,
utcDateChanged: blob.utcDateModified,
isSynced: true
});
}
private getProcessorForMimeType(mimeType: string): FileProcessor | null {
for (const processor of this.processors.values()) {
if (processor.canProcess(mimeType)) {
return processor;
}
}
return null;
}
/**
* Get all MIME types supported by all registered processors
*/
getAllSupportedMimeTypes(): string[] {
const supportedTypes = new Set<string>();
// Gather MIME types from all registered processors
for (const processor of this.processors.values()) {
const processorTypes = processor.getSupportedMimeTypes();
processorTypes.forEach(type => supportedTypes.add(type));
}
return Array.from(supportedTypes);
}
/**
* Get blobs that need OCR processing (those without text representation)
*/
getBlobsNeedingOCR(): Array<{ blobId: string; mimeType: string; entityType: 'note' | 'attachment'; entityId: string }> {
try {
const supportedMimes = this.getAllSupportedMimeTypes();
const placeholders = supportedMimes.map(() => '?').join(', ');
const noteBlobs = sql.getRows<{
blobId: string;
mimeType: string;
entityId: string;
}>(`
SELECT n.blobId, n.mime as mimeType, n.noteId as entityId
FROM notes n
JOIN blobs b ON n.blobId = b.blobId
WHERE (n.type = 'image' OR (n.type = 'file' AND n.mime IN (${placeholders})))
AND n.isDeleted = 0
AND n.blobId IS NOT NULL
AND b.textRepresentation IS NULL
`, supportedMimes);
const attachmentBlobs = sql.getRows<{
blobId: string;
mimeType: string;
entityId: string;
}>(`
SELECT a.blobId, a.mime as mimeType, a.attachmentId as entityId
FROM attachments a
JOIN blobs b ON a.blobId = b.blobId
WHERE (a.role = 'image' OR (a.role = 'file' AND a.mime IN (${placeholders})))
AND a.isDeleted = 0
AND a.blobId IS NOT NULL
AND b.textRepresentation IS NULL
`, supportedMimes);
// Combine results
const result = [
...noteBlobs.map(blob => ({ ...blob, entityType: 'note' as const })),
...attachmentBlobs.map(blob => ({ ...blob, entityType: 'attachment' as const }))
];
// Return all results (no need to filter by MIME type as we already did in the query)
return result;
} catch (error) {
log.error(`Failed to get blobs needing OCR: ${error}`);
return [];
}
}
}
export default new OCRService();