From 49fc7e48d4c1c8b9759b3260d2efec6815b834b7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 2 Apr 2026 09:49:03 +0300 Subject: [PATCH] feat(ocr): integrate with content language --- .../widgets/type_widgets/options/media.tsx | 11 +-- apps/server/src/services/ocr/ocr_service.ts | 81 +++++++++++++++++-- .../ocr/processors/image_processor.ts | 22 +---- .../ocr/processors/office_processor.ts | 44 +--------- .../services/ocr/processors/pdf_processor.ts | 48 +---------- .../services/ocr/processors/tiff_processor.ts | 46 +---------- apps/server/src/services/options_init.ts | 1 - packages/commons/src/lib/i18n.ts | 55 ++++++++----- 8 files changed, 118 insertions(+), 190 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/options/media.tsx b/apps/client/src/widgets/type_widgets/options/media.tsx index e8fd9dbb50..098e71176f 100644 --- a/apps/client/src/widgets/type_widgets/options/media.tsx +++ b/apps/client/src/widgets/type_widgets/options/media.tsx @@ -1,5 +1,5 @@ import { t } from "../../../services/i18n"; -import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox"; +import { FormTextBoxWithUnit } from "../../react/FormTextBox"; import FormToggle from "../../react/FormToggle"; import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks"; import OptionsRow from "./components/OptionsRow"; @@ -62,7 +62,6 @@ function ImageSettings() { function OcrSettings() { const [ ocrEnabled, setOcrEnabled ] = useTriliumOptionBool("ocrEnabled"); const [ ocrAutoProcess, setOcrAutoProcess ] = useTriliumOptionBool("ocrAutoProcessImages"); - const [ ocrLanguage, setOcrLanguage ] = useTriliumOption("ocrLanguage"); const [ ocrMinConfidence, setOcrMinConfidence ] = useTriliumOption("ocrMinConfidence"); return ( @@ -84,14 +83,6 @@ function OcrSettings() { /> - - - - 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"; + } + /** * Check if a MIME type is supported for OCR */ @@ -84,7 +149,7 @@ class OCRService { */ async extractTextFromFile(fileBuffer: Buffer, mimeType: string, options: OCRProcessingOptions = {}): Promise { try { - log.info(`Starting OCR text extraction for MIME type: ${mimeType}`); + log.info(`Starting OCR text extraction for MIME type: ${mimeType} with language: ${options.language || "auto-detect"}`); this.isProcessing = true; // Find appropriate processor @@ -147,7 +212,8 @@ class OCRService { throw new Error(`Cannot get image content for note ${noteId}`); } - const ocrResult = await this.extractTextFromFile(content, note.mime, options); + const language = this.resolveOcrLanguage(noteId, options.language); + const ocrResult = await this.extractTextFromFile(content, note.mime, { ...options, language }); // Store OCR result in blob await this.storeOCRResult(note.blobId, ocrResult); @@ -200,7 +266,8 @@ class OCRService { throw new Error(`Cannot get image content for attachment ${attachmentId}`); } - const ocrResult = await this.extractTextFromFile(content, attachment.mime, options); + const language = this.resolveOcrLanguage(attachment.ownerId, options.language); + const ocrResult = await this.extractTextFromFile(content, attachment.mime, { ...options, language }); // Store OCR result in blob await this.storeOCRResult(attachment.blobId, ocrResult); diff --git a/apps/server/src/services/ocr/processors/image_processor.ts b/apps/server/src/services/ocr/processors/image_processor.ts index ecde074be6..82c48bc8f8 100644 --- a/apps/server/src/services/ocr/processors/image_processor.ts +++ b/apps/server/src/services/ocr/processors/image_processor.ts @@ -43,7 +43,7 @@ export class ImageProcessor extends FileProcessor { // Set language if specified and different from current // Support multi-language format like 'ron+eng' - const language = options.language || this.getDefaultOCRLanguage(); + const language = options.language || "eng"; // Validate language format if (!this.isValidLanguageFormat(language)) { @@ -72,7 +72,7 @@ export class ImageProcessor extends FileProcessor { text: filteredText, confidence: overallConfidence, extractedAt: new Date().toISOString(), - language: options.language || this.getDefaultOCRLanguage(), + language: options.language || "eng", pageCount: 1 }; @@ -105,7 +105,7 @@ export class ImageProcessor extends FileProcessor { log.info(`Using worker path: ${workerPath}`); log.info(`Using core path: ${corePath}`); - this.worker = await Tesseract.createWorker(this.getDefaultOCRLanguage(), 1, { + this.worker = await Tesseract.createWorker("eng", 1, { workerPath, corePath, logger: (m: { status: string; progress: number }) => { @@ -131,21 +131,7 @@ export class ImageProcessor extends FileProcessor { log.info('Image OCR processor cleaned up'); } - /** - * Get default OCR language from options - */ - private getDefaultOCRLanguage(): string { - try { - const ocrLanguage = options.getOption('ocrLanguage'); - if (!ocrLanguage) { - throw new Error('OCR language not configured in user settings'); - } - return ocrLanguage; - } catch (error) { - log.error(`Failed to get default OCR language: ${error}`); - throw new Error('OCR language must be configured in settings before processing'); - } - } + /** * Filter text based on minimum confidence threshold diff --git a/apps/server/src/services/ocr/processors/office_processor.ts b/apps/server/src/services/ocr/processors/office_processor.ts index 826ada7b3d..01b04c8060 100644 --- a/apps/server/src/services/ocr/processors/office_processor.ts +++ b/apps/server/src/services/ocr/processors/office_processor.ts @@ -1,7 +1,6 @@ import * as officeParser from 'officeparser'; import log from '../../log.js'; -import options from '../../options.js'; import { OCRProcessingOptions,OCRResult } from '../ocr_service.js'; import { FileProcessor } from './file_processor.js'; import { ImageProcessor } from './image_processor.js'; @@ -38,11 +37,7 @@ export class OfficeProcessor extends FileProcessor { try { log.info('Starting Office document text extraction...'); - // Validate language format - const language = options.language || this.getDefaultOCRLanguage(); - if (!this.isValidLanguageFormat(language)) { - throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`); - } + const language = options.language || "eng"; // Extract text from Office document const data = await this.parseOfficeDocument(buffer); @@ -93,41 +88,4 @@ export class OfficeProcessor extends FileProcessor { async cleanup(): Promise { await this.imageProcessor.cleanup(); } - - /** - * Get default OCR language from options - */ - private getDefaultOCRLanguage(): string { - try { - const ocrLanguage = options.getOption('ocrLanguage'); - if (!ocrLanguage) { - throw new Error('OCR language not configured in user settings'); - } - return ocrLanguage; - } catch (error) { - log.error(`Failed to get default OCR language: ${error}`); - throw new Error('OCR language must be configured in settings before processing'); - } - } - - /** - * Validate OCR language format - * Supports single language (eng) or multi-language (ron+eng) - */ - private isValidLanguageFormat(language: string): boolean { - if (!language || typeof language !== 'string') { - return false; - } - - // Split by '+' for multi-language format - const languages = language.split('+'); - - // Check each language code (should be 2-7 characters, alphanumeric with underscores) - const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/; - - return languages.every(lang => { - const trimmed = lang.trim(); - return trimmed.length > 0 && validLanguagePattern.test(trimmed); - }); - } } diff --git a/apps/server/src/services/ocr/processors/pdf_processor.ts b/apps/server/src/services/ocr/processors/pdf_processor.ts index 4702a39b54..df5a43f49e 100644 --- a/apps/server/src/services/ocr/processors/pdf_processor.ts +++ b/apps/server/src/services/ocr/processors/pdf_processor.ts @@ -1,7 +1,6 @@ import pdfParse from 'pdf-parse'; import log from '../../log.js'; -import options from '../../options.js'; import { OCRProcessingOptions,OCRResult } from '../ocr_service.js'; import { FileProcessor } from './file_processor.js'; import { ImageProcessor } from './image_processor.js'; @@ -31,12 +30,6 @@ export class PDFProcessor extends FileProcessor { try { log.info('Starting PDF text extraction...'); - // Validate language format - const language = options.language || this.getDefaultOCRLanguage(); - if (!this.isValidLanguageFormat(language)) { - throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`); - } - // First try to extract existing text from PDF if (options.enablePDFTextExtraction !== false) { const textResult = await this.extractTextFromPDF(buffer, options); @@ -64,7 +57,7 @@ export class PDFProcessor extends FileProcessor { text: data.text.trim(), confidence: 0.99, // High confidence for direct text extraction extractedAt: new Date().toISOString(), - language: options.language || this.getDefaultOCRLanguage(), + language: options.language || "eng", pageCount: data.numpages }; } catch (error) { @@ -91,7 +84,7 @@ export class PDFProcessor extends FileProcessor { text: '[PDF OCR not fully implemented - would convert PDF pages to images and OCR each page]', confidence: 0.0, extractedAt: new Date().toISOString(), - language: options.language || this.getDefaultOCRLanguage(), + language: options.language || "eng", pageCount: 1 }; } catch (error) { @@ -107,41 +100,4 @@ export class PDFProcessor extends FileProcessor { async cleanup(): Promise { await this.imageProcessor.cleanup(); } - - /** - * Get default OCR language from options - */ - private getDefaultOCRLanguage(): string { - try { - const ocrLanguage = options.getOption('ocrLanguage'); - if (!ocrLanguage) { - throw new Error('OCR language not configured in user settings'); - } - return ocrLanguage; - } catch (error) { - log.error(`Failed to get default OCR language: ${error}`); - throw new Error('OCR language must be configured in settings before processing'); - } - } - - /** - * Validate OCR language format - * Supports single language (eng) or multi-language (ron+eng) - */ - private isValidLanguageFormat(language: string): boolean { - if (!language || typeof language !== 'string') { - return false; - } - - // Split by '+' for multi-language format - const languages = language.split('+'); - - // Check each language code (should be 2-7 characters, alphanumeric with underscores) - const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/; - - return languages.every(lang => { - const trimmed = lang.trim(); - return trimmed.length > 0 && validLanguagePattern.test(trimmed); - }); - } } diff --git a/apps/server/src/services/ocr/processors/tiff_processor.ts b/apps/server/src/services/ocr/processors/tiff_processor.ts index 6ce33b45c1..ef67029732 100644 --- a/apps/server/src/services/ocr/processors/tiff_processor.ts +++ b/apps/server/src/services/ocr/processors/tiff_processor.ts @@ -1,7 +1,6 @@ import sharp from 'sharp'; import log from '../../log.js'; -import options from '../../options.js'; import { OCRProcessingOptions,OCRResult } from '../ocr_service.js'; import { FileProcessor } from './file_processor.js'; import { ImageProcessor } from './image_processor.js'; @@ -30,11 +29,7 @@ export class TIFFProcessor extends FileProcessor { try { log.info('Starting TIFF text extraction...'); - // Validate language format - const language = options.language || this.getDefaultOCRLanguage(); - if (!this.isValidLanguageFormat(language)) { - throw new Error(`Invalid OCR language format: ${language}. Use format like 'eng' or 'ron+eng'`); - } + const language = options.language || "eng"; // Check if this is a multi-page TIFF const metadata = await sharp(buffer).metadata(); @@ -75,7 +70,7 @@ export class TIFFProcessor extends FileProcessor { text: combinedText.trim(), confidence: averageConfidence, extractedAt: new Date().toISOString(), - language: options.language || this.getDefaultOCRLanguage(), + language, pageCount }; @@ -95,41 +90,4 @@ export class TIFFProcessor extends FileProcessor { async cleanup(): Promise { await this.imageProcessor.cleanup(); } - - /** - * Get default OCR language from options - */ - private getDefaultOCRLanguage(): string { - try { - const ocrLanguage = options.getOption('ocrLanguage'); - if (!ocrLanguage) { - throw new Error('OCR language not configured in user settings'); - } - return ocrLanguage; - } catch (error) { - log.error(`Failed to get default OCR language: ${error}`); - throw new Error('OCR language must be configured in settings before processing'); - } - } - - /** - * Validate OCR language format - * Supports single language (eng) or multi-language (ron+eng) - */ - private isValidLanguageFormat(language: string): boolean { - if (!language || typeof language !== 'string') { - return false; - } - - // Split by '+' for multi-language format - const languages = language.split('+'); - - // Check each language code (should be 2-7 characters, alphanumeric with underscores) - const validLanguagePattern = /^[a-zA-Z]{2,3}(_[a-zA-Z]{2,3})?$/; - - return languages.every(lang => { - const trimmed = lang.trim(); - return trimmed.length > 0 && validLanguagePattern.test(trimmed); - }); - } } diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index e559d40d46..d398a252a2 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -216,7 +216,6 @@ const defaultOptions: DefaultOption[] = [ // OCR options { name: "ocrEnabled", value: "false", isSynced: true }, - { name: "ocrLanguage", value: "eng", isSynced: true }, { name: "ocrAutoProcessImages", value: "true", isSynced: true }, { name: "ocrMinConfidence", value: "0.55", isSynced: true }, ]; diff --git a/packages/commons/src/lib/i18n.ts b/packages/commons/src/lib/i18n.ts index e2cc3231aa..86b5829f64 100644 --- a/packages/commons/src/lib/i18n.ts +++ b/packages/commons/src/lib/i18n.ts @@ -9,27 +9,29 @@ export interface Locale { devOnly?: boolean; /** The value to pass to `--lang` for the Electron instance in order to set it as a locale. Not setting it will hide it from the list of supported locales. */ electronLocale?: "en" | "de" | "es" | "fr" | "zh_CN" | "zh_TW" | "ro" | "af" | "am" | "ar" | "bg" | "bn" | "ca" | "cs" | "da" | "el" | "en_GB" | "es_419" | "et" | "fa" | "fi" | "fil" | "gu" | "he" | "hi" | "hr" | "hu" | "id" | "it" | "ja" | "kn" | "ko" | "lt" | "lv" | "ml" | "mr" | "ms" | "nb" | "nl" | "pl" | "pt_BR" | "pt_PT" | "ru" | "sk" | "sl" | "sr" | "sv" | "sw" | "ta" | "te" | "th" | "tr" | "uk" | "ur" | "vi"; + /** The Tesseract OCR language code for this locale (e.g. "eng", "fra", "deu"). See https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html */ + tesseractCode?: "eng" | "deu" | "spa" | "fra" | "gle" | "ita" | "hin" | "jpn" | "por" | "pol" | "ron" | "rus" | "chi_sim" | "chi_tra" | "ukr" | "ara" | "heb" | "kur" | "fas" | "kor"; } // When adding a new locale, prefer the version with hyphen instead of underscore. const UNSORTED_LOCALES = [ - { id: "cn", name: "简体中文", electronLocale: "zh_CN" }, - { id: "de", name: "Deutsch", electronLocale: "de" }, - { id: "en", name: "English (United States)", electronLocale: "en" }, - { id: "en-GB", name: "English (United Kingdom)", electronLocale: "en_GB" }, - { id: "es", name: "Español", electronLocale: "es" }, - { id: "fr", name: "Français", electronLocale: "fr" }, - { id: "ga", name: "Gaeilge", electronLocale: "en" }, - { id: "it", name: "Italiano", electronLocale: "it" }, - { id: "hi", name: "हिन्दी", electronLocale: "hi" }, - { id: "ja", name: "日本語", electronLocale: "ja" }, - { id: "pt_br", name: "Português (Brasil)", electronLocale: "pt_BR" }, - { id: "pt", name: "Português (Portugal)", electronLocale: "pt_PT" }, - { id: "pl", name: "Polski", electronLocale: "pl" }, - { id: "ro", name: "Română", electronLocale: "ro" }, - { id: "ru", name: "Русский", electronLocale: "ru" }, - { id: "tw", name: "繁體中文", electronLocale: "zh_TW" }, - { id: "uk", name: "Українська", electronLocale: "uk" }, + { id: "cn", name: "简体中文", electronLocale: "zh_CN", tesseractCode: "chi_sim" }, + { id: "de", name: "Deutsch", electronLocale: "de", tesseractCode: "deu" }, + { id: "en", name: "English (United States)", electronLocale: "en", tesseractCode: "eng" }, + { id: "en-GB", name: "English (United Kingdom)", electronLocale: "en_GB", tesseractCode: "eng" }, + { id: "es", name: "Español", electronLocale: "es", tesseractCode: "spa" }, + { id: "fr", name: "Français", electronLocale: "fr", tesseractCode: "fra" }, + { id: "ga", name: "Gaeilge", electronLocale: "en", tesseractCode: "gle" }, + { id: "it", name: "Italiano", electronLocale: "it", tesseractCode: "ita" }, + { id: "hi", name: "हिन्दी", electronLocale: "hi", tesseractCode: "hin" }, + { id: "ja", name: "日本語", electronLocale: "ja", tesseractCode: "jpn" }, + { id: "pt_br", name: "Português (Brasil)", electronLocale: "pt_BR", tesseractCode: "por" }, + { id: "pt", name: "Português (Portugal)", electronLocale: "pt_PT", tesseractCode: "por" }, + { id: "pl", name: "Polski", electronLocale: "pl", tesseractCode: "pol" }, + { id: "ro", name: "Română", electronLocale: "ro", tesseractCode: "ron" }, + { id: "ru", name: "Русский", electronLocale: "ru", tesseractCode: "rus" }, + { id: "tw", name: "繁體中文", electronLocale: "zh_TW", tesseractCode: "chi_tra" }, + { id: "uk", name: "Українська", electronLocale: "uk", tesseractCode: "ukr" }, /** * Development-only languages. @@ -53,25 +55,29 @@ const UNSORTED_LOCALES = [ id: "ar", name: "اَلْعَرَبِيَّةُ", rtl: true, - electronLocale: "ar" + electronLocale: "ar", + tesseractCode: "ara" }, { // Hebrew id: "he", name: "עברית", rtl: true, - contentOnly: true + contentOnly: true, + tesseractCode: "heb" }, { // Kurdish id: "ku", name: "کوردی", rtl: true, - contentOnly: true + contentOnly: true, + tesseractCode: "kur" }, { // Persian id: "fa", name: "فارسی", rtl: true, - contentOnly: true + contentOnly: true, + tesseractCode: "fas" } ] as const; @@ -82,3 +88,10 @@ export const LOCALES: Locale[] = Array.from(UNSORTED_LOCALES) export type LOCALE_IDS = typeof UNSORTED_LOCALES[number]["id"]; /** A type containing a string union of all the supported locales that are not content-only (i.e. can be used as the UI language). */ export type DISPLAYABLE_LOCALE_IDS = Exclude["id"]; + +/** + * Returns the Tesseract OCR language code for the given locale ID, or `null` if not mapped. + */ +export function getTesseractCode(localeId: string): string | null { + return LOCALES.find((l) => l.id === localeId)?.tesseractCode ?? null; +}