From 216282a748cd79bfd3acf4521d0001c638c609fe Mon Sep 17 00:00:00 2001 From: smjrifle Date: Wed, 21 Oct 2026 23:13:49 +0545 Subject: [PATCH 1/8] feat(ocr): add Apple Vision native and Tesseract OCR engine support - Add IOcrEngine, OcrBlock, OcrResult, and OcrEngineRegistry abstractions in Filee.Core - Implement native Apple Vision OCR on macOS via Objective-C runtime P/Invoke (zero-download) - Implement cross-platform Tesseract CLI engine in Filee.Engines for multilingual and Linux support - Integrate OCR into PdfDocumentReader and DocxWriterConverter for scanned PDF conversions - Add system package installation and removal flow for Tesseract via Homebrew - Add OCR settings (engine, language hints) to PresetEditor - Translate all 14 OCR keys across all 10 supported locales and add full test coverage --- docs/ENGINES.md | 2 + src/Filee.App/Assets/i18n/ar.json | 14 + src/Filee.App/Assets/i18n/en.json | 14 + src/Filee.App/Assets/i18n/es.json | 14 + src/Filee.App/Assets/i18n/hi.json | 14 + src/Filee.App/Assets/i18n/ja.json | 14 + src/Filee.App/Assets/i18n/ko.json | 14 + src/Filee.App/Assets/i18n/pl.json | 14 + src/Filee.App/Assets/i18n/pt-BR.json | 14 + src/Filee.App/Assets/i18n/ru.json | 14 + src/Filee.App/Assets/i18n/zh-CN.json | 14 + src/Filee.App/Services/AppHost.cs | 3 + .../Services/EngineDownloadService.cs | 258 +++++++++++++++-- .../ViewModels/PresetEditorViewModel.cs | 115 +++++++- src/Filee.App/Views/PresetEditorView.axaml | 32 +++ src/Filee.Cli/CliHost.cs | 2 + src/Filee.Cli/Filee.Cli.csproj | 1 + src/Filee.Core/Ocr/IOcrEngine.cs | 30 ++ src/Filee.Core/Ocr/OcrEngineRegistry.cs | 135 +++++++++ src/Filee.Core/Ocr/OcrResult.cs | 39 +++ src/Filee.Core/Presets/Preset.cs | 10 + src/Filee.Engines/EngineRegistry.cs | 72 ++--- src/Filee.Engines/Filee.Engines.csproj | 1 + src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs | 9 +- src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs | 2 +- .../Infrastructure/EngineDownloads.cs | 11 +- .../Infrastructure/EngineEnvironment.cs | 7 + .../Infrastructure/EngineInstaller.cs | 4 +- src/Filee.Engines/Ocr/ImageOcrConverter.cs | 96 +++++++ src/Filee.Engines/Ocr/TesseractOcrEngine.cs | 243 ++++++++++++++++ .../Office/DocxWriterConverter.cs | 2 +- src/Filee.Engines/Pdf/PdfDocumentReader.cs | 110 ++++++- src/Filee.Engines/Pdf/PdfTextConverter.cs | 6 +- .../MacOsVisionOcrEngine.cs | 272 ++++++++++++++++++ tests/Filee.App.Tests/EnginesPageTests.cs | 13 + .../OcrEngineRegistryTests.cs | 88 ++++++ .../Filee.Engines.Tests/TesseractOcrTests.cs | 74 +++++ .../Filee.Platform.Tests.csproj | 1 + tests/Filee.Platform.Tests/VisionOcrTests.cs | 198 +++++++++++++ 39 files changed, 1897 insertions(+), 79 deletions(-) create mode 100644 src/Filee.Core/Ocr/IOcrEngine.cs create mode 100644 src/Filee.Core/Ocr/OcrEngineRegistry.cs create mode 100644 src/Filee.Core/Ocr/OcrResult.cs create mode 100644 src/Filee.Engines/Ocr/ImageOcrConverter.cs create mode 100644 src/Filee.Engines/Ocr/TesseractOcrEngine.cs create mode 100644 src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs create mode 100644 tests/Filee.Core.Tests/OcrEngineRegistryTests.cs create mode 100644 tests/Filee.Engines.Tests/TesseractOcrTests.cs create mode 100644 tests/Filee.Platform.Tests/VisionOcrTests.cs diff --git a/docs/ENGINES.md b/docs/ENGINES.md index a7c4ce259..38907d758 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -34,6 +34,8 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it | | FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, Live / Motion Photos → their video, a clip of a video → an iPhone Live Photo or Android Motion Photo (experimental), images → HEIC | | Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR; e-books → PDF (ranked above the HWPX writer, see below) | +| Apple Vision | part of macOS (macOS only) | **scanned PDFs and images → editable text / DOCX / Markdown / HWPX** via native neural OCR (Latin, CJK, Cyrillic); zero download size | +| Tesseract | **optional, install yourself** (~30 MB + the trained data of each language) | OCR for scripts Apple Vision doesn't know: Devanagari (Nepali, Hindi, Sanskrit, Marathi), Cyrillic beyond Russian/Ukrainian, Thai, Greek, Arabic script and more; the only OCR on Windows and Linux (Linux has no built-in option, so this is required there) | DOCX, XLSX, XLS, ODS and PPTX → PDF need neither Microsoft Office nor LibreOffice: they are read in-process, written as HWPX and rendered by rhwp. Anything the built-in readers understand (including PDF) is written as DOCX by the DOCX writer. diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 7ee866f47..c27034f0e 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -140,6 +140,7 @@ "engine.ghostscript.description": "تحويل EPS وPostScript وملفات Illustrator القديمة إلى PDF وصور؛ وتحويل PDF إلى EPS وPS.", "engine.calibre.description": "صيغ الكتب الإلكترونية النادرة (LIT وLRF وCHM وPDB وRB…) وإخراج Kindle ‏(MOBI وAZW3).\nعند وضعه فوق محرك كتابة HWPX، يُنشئ أيضًا PDF من الكتب الإلكترونية (بسرعة أكبر بكثير للكتب الطويلة).", "engine.libreoffice.description": "قراءة الصيغ القديمة والأقل شيوعًا: DOC وPPT وPages وKeynote وNumbers وWordPerfect وClarisWorks وPublisher وVisio…\nوالحفظ بصيغ DOC وXLS وPPT وODT وODP وقوالب OpenDocument وEMF.", + "engine.image-ocr.description": "استخراج النص من ملفات PDF الممسوحة ضوئيًا والصور إلى مستندات قابلة للتحرير (Word والنص وHWPX).", "profile.images": "الصور", "profile.pdf": "PDF", "profile.office": "المستندات", @@ -519,6 +520,14 @@ "presets.leave_out_pictures": "استبعاد الصور (النص فقط)", "presets.leave_out_pictures_hint": "عند التعطيل: تُحفَظ الصور في مجلد name_files بجانب ملف Markdown، وتُدرَج روابطها فيه.\nعند التفعيل: النص فقط، دون مجلد.", "presets.target_folder": "مجلد (استخراج الأرشيفات)", + "presets.group.ocr": "التعرف البصري على الأحرف (OCR)", + "presets.ocr_enabled": "التعرف على النص في المستندات الممسوحة ضوئيًا والصور", + "presets.ocr_hint": "استخراج نص قابل للتحرير من الصفحات الممسوحة ضوئيًا والصور إلى Word والنص والمستندات.", + "presets.ocr_language": "لغة التعرف", + "presets.ocr_language_hint": "اللغة المستخدمة للتعرف بدقة على الأحرف والكلمات.", + "presets.ocr_language.auto": "تلقائي / الافتراضي للنظام", + "presets.ocr_engine_required": "يتطلب Tesseract. قم بتثبيته من الإعدادات ← المحركات", + "presets.ocr_disabled_linux": "لا يحتوي Linux على OCR مدمج. قم بتثبيت Tesseract من الإعدادات ← المحركات للتمكين.", "presets.group.media": "الفيديو والصوت", "presets.media_quality": "الجودة", "presets.media_quality_hint": "عندما تستطيع الصيغة الجديدة الاحتفاظ بالترميزات كما هي (MOV من iPhone إلى MP4)، تتغير الحاوية فقط.\nدون فقدان جودة، مع الاحتفاظ بـ HDR، خلال ثوانٍ.\nخيار «صغير» يعيد الترميز دائمًا لتقليل الحجم.", @@ -652,6 +661,11 @@ "engines.guidance.linux.libreoffice": "ثبّته باستخدام مدير الحزم (على Ubuntu/Debian: sudo apt install libreoffice)، ثم اختر «التحقق مجددًا».\nيتطلب فتح HWP أيضًا Java وإضافة H2Orestart.", "engines.guidance.linux.ghostscript": "ثبّته باستخدام مدير الحزم (على Ubuntu/Debian: sudo apt install ghostscript)، ثم اختر «التحقق مجددًا».", "engines.guidance.windows": "لا يوفر Filee تنزيلًا لـ {0} لهذا الحاسوب.\nاختر «استخدام نسختي…» وحدّد نسخة مثبّتة.", + "engines.package.tesseract.name": "Tesseract (التعرف على النصوص متعدد اللغات)", + "engines.package.tesseract.description": "يتعرف على النص بأكثر من 100 لغة، بما في ذلك الديفاناغارية (النيبالية/الهندية) والعربية والخطوط النادرة.\nعلى macOS، يعمل Apple Vision أصلاً للغات الإنجليزية ولغات CJK واللغات الأوروبية بدونه.", + "engines.guidance.macos.tesseract": "قم بتثبيت Tesseract باستخدام Homebrew (brew install tesseract tesseract-lang)، ثم اختر “تحقق مرة أخرى”.\nأو اختر “استخدام نسختي…” وحدد مجلد bin الخاص به.", + "engines.guidance.linux.tesseract": "قم بتثبيت Tesseract باستخدام مدير الحزم (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)، ثم اختر “تحقق مرة أخرى”.\nأو اختر “استخدام نسختي…” وحدد مجلد البرنامج الخاص به.", + "engines.guidance.windows.tesseract": "قم بتثبيت Tesseract من UB-Mannheim (github.com/UB-Mannheim/tesseract)، ثم اختر “تحقق مرة أخرى” أو حدد مجلد التثبيت الخاص به.", "engines.use_own_app_folder": "اختر المجلد الذي يحتوي على {0}.app", "engines.progress_detail": "{0} من {1} · {2}/ث · {3}", "engines.eta.estimating": "جارٍ تقدير الوقت المتبقي…", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index d6024c303..31353d40f 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -147,6 +147,7 @@ "engine.ghostscript.description": "EPS, PostScript and older Illustrator files to PDF and images; PDF to EPS and PS.", "engine.calibre.description": "Rare e-book formats (LIT, LRF, CHM, PDB, RB…) and Kindle output (MOBI, AZW3).\nAbove the HWPX writer, it also makes the PDFs of e-books (much faster for long books).", "engine.libreoffice.description": "Reads older and less common formats: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nAlso saves as DOC, XLS, PPT, ODT, ODP, OpenDocument templates and EMF.", + "engine.image-ocr.description": "Extracts text from scanned PDFs and pictures into editable documents (Word, text, HWPX).", "profile.images": "Images", "profile.pdf": "PDF", @@ -534,6 +535,14 @@ "presets.leave_out_pictures": "Leave out pictures (text only)", "presets.leave_out_pictures_hint": "Off: the pictures are saved in a \"name_files\" folder next to the Markdown file and linked from it.\nOn: only the text, no folder.", "presets.target_folder": "Folder (extract archives)", + "presets.group.ocr": "Optical Character Recognition (OCR)", + "presets.ocr_enabled": "Recognize text in scans and images", + "presets.ocr_hint": "Extracts editable text from scanned pages and images into Word, text, and documents.", + "presets.ocr_language": "Recognition language", + "presets.ocr_language_hint": "Language used to recognize characters and words accurately.", + "presets.ocr_language.auto": "Automatic / System Default", + "presets.ocr_engine_required": "Needs Tesseract. Install it in Settings → Engines", + "presets.ocr_disabled_linux": "Linux has no built-in OCR. Install Tesseract in Settings → Engines to enable.", "presets.group.media": "Video & audio", "presets.media_quality": "Quality", "presets.media_quality_hint": "When the new format can hold the codecs as they are (iPhone MOV → MP4), the video is only rewrapped.\nNo quality lost, HDR kept, done in seconds.\nSmall always re-encodes to make the file smaller.", @@ -669,6 +678,11 @@ "engines.guidance.linux.libreoffice": "Install it with your package manager (Ubuntu/Debian: sudo apt install libreoffice), then choose “Check again”.\nOpening HWP also needs Java and the H2Orestart extension.", "engines.guidance.linux.ghostscript": "Install it with your package manager (Ubuntu/Debian: sudo apt install ghostscript), then choose “Check again”.", "engines.guidance.windows": "Filee has no download of {0} for this PC.\nChoose “Use my copy…” and select an installed copy.", + "engines.package.tesseract.name": "Tesseract (multilingual text recognition)", + "engines.package.tesseract.description": "Recognizes text in over 100 languages, including Devanagari (Nepali/Hindi), Arabic, and rare scripts.\nOn macOS, Apple Vision works natively for English, CJK, and European languages without it.", + "engines.guidance.macos.tesseract": "Install Tesseract with Homebrew (brew install tesseract tesseract-lang), then choose “Check again”.\nOr choose “Use my copy…” and select its bin folder.", + "engines.guidance.linux.tesseract": "Install Tesseract with your package manager (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), then choose “Check again”.\nOr choose “Use my copy…” and select its program folder.", + "engines.guidance.windows.tesseract": "Install Tesseract from UB-Mannheim (github.com/UB-Mannheim/tesseract), then choose “Check again” or select its install folder.", "engines.use_own_app_folder": "Choose the folder containing {0}.app", "engines.progress_detail": "{0} of {1} · {2}/s · {3}", "engines.eta.estimating": "estimating time left…", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 89007a3ee..b3da5d10b 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -140,6 +140,7 @@ "engine.ghostscript.description": "EPS, PostScript y archivos antiguos de Illustrator a PDF e imágenes; PDF a EPS y PS.", "engine.calibre.description": "Formatos raros de libros electrónicos (LIT, LRF, CHM, PDB, RB…) y salida Kindle (MOBI, AZW3).\nPor encima del escritor HWPX, también crea los PDF de libros electrónicos (mucho más rápido para libros largos).", "engine.libreoffice.description": "Lee formatos antiguos y poco habituales: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nTambién guarda como DOC, XLS, PPT, ODT, ODP, plantillas OpenDocument y EMF.", + "engine.image-ocr.description": "Extrae texto de PDF escaneados e imágenes a documentos editables (Word, texto, HWPX).", "profile.images": "Imágenes", "profile.pdf": "PDF", "profile.office": "Documentos", @@ -519,6 +520,14 @@ "presets.leave_out_pictures": "Omitir imágenes (solo texto)", "presets.leave_out_pictures_hint": "Desactivado: las imágenes se guardan en una carpeta \"nombre_files\" junto al archivo Markdown y se enlazan desde él.\nActivado: solo el texto, sin carpeta.", "presets.target_folder": "Carpeta (extraer archivos comprimidos)", + "presets.group.ocr": "Reconocimiento óptico de caracteres (OCR)", + "presets.ocr_enabled": "Reconocer texto en escaneos e imágenes", + "presets.ocr_hint": "Extrae texto editable de páginas escaneadas e imágenes a Word, texto y documentos.", + "presets.ocr_language": "Idioma de reconocimiento", + "presets.ocr_language_hint": "Idioma utilizado para reconocer caracteres y palabras con precisión.", + "presets.ocr_language.auto": "Automático / Predeterminado del sistema", + "presets.ocr_engine_required": "Requiere Tesseract. Instálalo en Configuración → Motores", + "presets.ocr_disabled_linux": "Linux no tiene OCR integrado. Instala Tesseract en Configuración → Motores para activarlo.", "presets.group.media": "Vídeo y audio", "presets.media_quality": "Calidad", "presets.media_quality_hint": "Cuando el formato nuevo puede conservar los códecs tal como están (MOV de iPhone → MP4), el vídeo solo se cambia de contenedor sin recodificar.\nSin pérdida de calidad, HDR conservado, listo en segundos.\nPequeño siempre recodifica para que el archivo pese menos.", @@ -652,6 +661,11 @@ "engines.guidance.linux.libreoffice": "Instálalo con tu gestor de paquetes (Ubuntu/Debian: sudo apt install libreoffice) y luego elige «Comprobar otra vez».\nAbrir HWP necesita además Java y la extensión H2Orestart.", "engines.guidance.linux.ghostscript": "Instálalo con tu gestor de paquetes (Ubuntu/Debian: sudo apt install ghostscript) y luego elige «Comprobar otra vez».", "engines.guidance.windows": "Filee no tiene una descarga de {0} para este PC.\nElige «Usar mi copia…» y selecciona una copia instalada.", + "engines.package.tesseract.name": "Tesseract (reconocimiento de texto multilingüe)", + "engines.package.tesseract.description": "Reconoce texto en más de 100 idiomas, incluidos devanagari (nepalí/hindi), árabe y alfabetos poco comunes.\nEn macOS, Apple Vision funciona de forma nativa para inglés, CJK e idiomas europeos sin él.", + "engines.guidance.macos.tesseract": "Instala Tesseract con Homebrew (brew install tesseract tesseract-lang), luego elige “Comprobar de nuevo”.\nO elige “Usar mi copia…” y selecciona su carpeta bin.", + "engines.guidance.linux.tesseract": "Instala Tesseract con el gestor de paquetes (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), luego elige “Comprobar de nuevo”.\nO elige “Usar mi copia…” y selecciona su carpeta de programas.", + "engines.guidance.windows.tesseract": "Instala Tesseract desde UB-Mannheim (github.com/UB-Mannheim/tesseract), luego elige “Comprobar de nuevo” o selecciona su carpeta de instalación.", "engines.use_own_app_folder": "Elige la carpeta que contiene {0}.app", "engines.progress_detail": "{0} de {1} · {2}/s · {3}", "engines.eta.estimating": "calculando el tiempo restante…", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index dde786072..deaca3833 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -140,6 +140,7 @@ "engine.ghostscript.description": "EPS, PostScript और पुरानी Illustrator फ़ाइलें PDF और इमेज में; PDF से EPS और PS।", "engine.calibre.description": "दुर्लभ ई-बुक फ़ॉर्मैट (LIT, LRF, CHM, PDB, RB…) और Kindle आउटपुट (MOBI, AZW3)।\nHWPX writer के ऊपर होने पर यह ई-बुक के PDF भी बनाता है (लंबी किताबों के लिए बहुत तेज़)।", "engine.libreoffice.description": "पुराने और कम प्रचलित फ़ॉर्मैट पढ़ता है: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nDOC, XLS, PPT, ODT, ODP, OpenDocument टेम्पलेट और EMF के रूप में सहेजता भी है।", + "engine.image-ocr.description": "स्कैन किए गए PDF और चित्रों से टेक्स्ट निकाल कर संपादन योग्य दस्तावेज़ों (Word, टेक्स्ट, HWPX) में बदलता है।", "profile.images": "इमेज", "profile.pdf": "PDF", "profile.office": "दस्तावेज़", @@ -519,6 +520,14 @@ "presets.leave_out_pictures": "तस्वीरें छोड़ दें (केवल टेक्स्ट)", "presets.leave_out_pictures_hint": "बंद: तस्वीरें Markdown फ़ाइल के बगल में \"name_files\" फ़ोल्डर में सहेजी जाती हैं और उसी से लिंक होती हैं।\nचालू: केवल टेक्स्ट, कोई फ़ोल्डर नहीं।", "presets.target_folder": "फ़ोल्डर (आर्काइव निकालने के लिए)", + "presets.group.ocr": "ऑप्टिकल कैरेक्टर रिकग्निशन (OCR)", + "presets.ocr_enabled": "स्कैन और छवियों में टेक्स्ट पहचानें", + "presets.ocr_hint": "स्कैन किए गए पेजों और छवियों से संपादन योग्य टेक्स्ट निकाल कर Word, टेक्स्ट और दस्तावेज़ों में बदलें।", + "presets.ocr_language": "पहचान भाषा", + "presets.ocr_language_hint": "वर्णों और शब्दों को सटीक रूप से पहचानने के लिए उपयोग की जाने वाली भाषा।", + "presets.ocr_language.auto": "स्वचालित / सिस्टम डिफ़ॉल्ट", + "presets.ocr_engine_required": "Tesseract की आवश्यकता है। इसे सेटिंग्स → इंजन में इंस्टॉल करें", + "presets.ocr_disabled_linux": "Linux में कोई अंतर्निहित OCR नहीं है। सक्षम करने के लिए सेटिंग्स → इंजन में Tesseract इंस्टॉल करें।", "presets.group.media": "वीडियो और ऑडियो", "presets.media_quality": "क्वालिटी", "presets.media_quality_hint": "जब नया फ़ॉर्मैट कोडेक को वैसे ही रख सकता है (iPhone MOV → MP4), तो वीडियो केवल दोबारा लपेटा जाता है।\nकोई क्वालिटी नहीं घटती, HDR बना रहता है, और कुछ सेकंड में हो जाता है।\nछोटा विकल्प फ़ाइल घटाने के लिए हमेशा दोबारा एन्कोड करता है।", @@ -652,6 +661,11 @@ "engines.guidance.linux.libreoffice": "इसे अपने पैकेज मैनेजर से इंस्टॉल करें (Ubuntu/Debian: sudo apt install libreoffice), फिर “फिर से जाँचें” चुनें।\nHWP खोलने के लिए Java और H2Orestart एक्सटेंशन भी चाहिए।", "engines.guidance.linux.ghostscript": "इसे अपने पैकेज मैनेजर से इंस्टॉल करें (Ubuntu/Debian: sudo apt install ghostscript), फिर “फिर से जाँचें” चुनें।", "engines.guidance.windows": "इस PC के लिए Filee के पास {0} का डाउनलोड नहीं है।\n“मेरी कॉपी इस्तेमाल करें…” चुनें और इंस्टॉल की गई कॉपी चुनें।", + "engines.package.tesseract.name": "Tesseract (बहुभाषी टेक्स्ट पहचान)", + "engines.package.tesseract.description": "देवनागरी (नेपाली/हिंदी), अरबी और दुर्लभ लिपियों सहित 100 से अधिक भाषाओं में टेक्स्ट पहचानता है।\nmacOS पर Apple Vision अंग्रेजी, CJK और यूरोपीय भाषाओं के लिए बिना इसके भी काम करता है।", + "engines.guidance.macos.tesseract": "Homebrew से Tesseract इंस्टॉल करें (brew install tesseract tesseract-lang), फिर “दोबारा जांचें” चुनें।\nया “मेरी कॉपी इस्तेमाल करें…” चुनें और इसके bin फ़ोल्डर का चयन करें।", + "engines.guidance.linux.tesseract": "अपने पैकेज मैनेजर से Tesseract इंस्टॉल करें (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), फिर “दोबारा जांचें” चुनें।\nया “मेरी कॉपी इस्तेमाल करें…” चुनें और इसका प्रोग्राम फ़ोल्डर चुनें।", + "engines.guidance.windows.tesseract": "UB-Mannheim (github.com/UB-Mannheim/tesseract) से Tesseract इंस्टॉल करें, फिर “दोबारा जांचें” चुनें या इसके इंस्टाल फ़ोल्डर का चयन करें।", "engines.use_own_app_folder": "वह फ़ोल्डर चुनें जिसमें {0}.app है", "engines.progress_detail": "{1} में से {0} · {2}/s · {3}", "engines.eta.estimating": "बचा हुआ समय आँका जा रहा है…", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index afc5f5d33..bc5373247 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -140,6 +140,7 @@ "engine.ghostscript.description": "EPS、PostScript、古いIllustratorファイルをPDFや画像に変換し、PDFをEPSやPSに変換します。", "engine.calibre.description": "一般的でない電子書籍形式(LIT、LRF、CHM、PDB、RBなど)を変換し、Kindle用のMOBI、AZW3形式で保存できます。\n「HWPX writer」より上に置くと、電子書籍のPDF作成もCalibreが担当します(長い本では大幅に速くなります)。", "engine.libreoffice.description": "古い形式や一般的でない形式(DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visioなど)を読み込みます。\nDOC、XLS、PPT、ODT、ODP、OpenDocumentテンプレート、EMF形式での保存にも対応します。", + "engine.image-ocr.description": "スキャンしたPDFや画像からテキストを抽出し、編集可能な文書(Word、テキスト、HWPX)に変換します。", "profile.images": "画像", "profile.pdf": "PDF", "profile.office": "文書", @@ -519,6 +520,14 @@ "presets.leave_out_pictures": "画像を含めない(テキストのみ)", "presets.leave_out_pictures_hint": "オフ:画像をMarkdownファイルと同じ場所の「name_files」フォルダーに保存し、Markdownファイルからリンクします。\nオン:テキストだけを保存し、フォルダーは作成しません。", "presets.target_folder": "フォルダー(アーカイブを展開)", + "presets.group.ocr": "光学式文字認識 (OCR)", + "presets.ocr_enabled": "スキャンや画像内のテキストを認識", + "presets.ocr_hint": "スキャンしたページや画像から編集可能なテキストを抽出し、Word、テキスト、ドキュメントに変換します。", + "presets.ocr_language": "認識言語", + "presets.ocr_language_hint": "文字や単語を正確に認識するために使用する言語。", + "presets.ocr_language.auto": "自動 / システムのデフォルト", + "presets.ocr_engine_required": "Tesseractが必要です。設定 → エンジンからインストールしてください", + "presets.ocr_disabled_linux": "Linuxには組み込みOCRがありません。有効にするには設定 → エンジンでTesseractをインストールしてください。", "presets.group.media": "動画・音声", "presets.media_quality": "品質", "presets.media_quality_hint": "変換先が元のコーデックに対応している場合(iPhoneのMOV → MP4など)、動画を再エンコードせずに形式だけを変えます。\n画質を保ち、HDRも維持したまま数秒で完了します。\n「小さめ」はファイルサイズを減らすため、常に再エンコードします。", @@ -652,6 +661,11 @@ "engines.guidance.linux.libreoffice": "パッケージマネージャーでインストール(Ubuntu/Debian:sudo apt install libreoffice)してから、「再確認」を選んでください。\nHWPを開くには、JavaとH2Orestart拡張機能も必要です。", "engines.guidance.linux.ghostscript": "パッケージマネージャーでインストール(Ubuntu/Debian:sudo apt install ghostscript)してから、「再確認」を選んでください。", "engines.guidance.windows": "このPC向けの{0}はFileeからダウンロードできません。\n「手元のプログラムを使う…」を選び、インストール済みのものを指定してください。", + "engines.package.tesseract.name": "Tesseract(多言語テキスト認識)", + "engines.package.tesseract.description": "デーヴァナーガリー(ネパール語/ヒンディー語)、アラビア語、希少言語を含む100以上の言語のテキストを認識します。\nmacOSではApple Visionが英語、CJK、ヨーロッパ言語をネイティブにサポートしています。", + "engines.guidance.macos.tesseract": "HomebrewでTesseractをインストール(brew install tesseract tesseract-lang)してから「再確認」を選択してください。\nまたは「手持ちのコピーを使う…」を選択してbinフォルダーを指定してください。", + "engines.guidance.linux.tesseract": "パッケージマネージャーでTesseractをインストール(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)してから「再確認」を選択してください。\nまたは「手持ちのコピーを使う…」を選択してプログラムフォルダーを指定してください。", + "engines.guidance.windows.tesseract": "UB-Mannheim(github.com/UB-Mannheim/tesseract)からTesseractをインストールし、「再確認」を選択するかインストールフォルダーを指定してください。", "engines.use_own_app_folder": "{0}.appを含むフォルダーを選択してください", "engines.progress_detail": "{0}/{1} · {2}/秒 · {3}", "engines.eta.estimating": "残り時間を計算中…", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 115bfc7e1..379ada5ee 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -147,6 +147,7 @@ "engine.ghostscript.description": "EPS, 포스트스크립트, 예전 일러스트레이터 파일을 PDF와 이미지로, PDF를 EPS·PS로.", "engine.calibre.description": "드문 전자책 형식(LIT, LRF, CHM, PDB, RB…)과 킨들용 출력(MOBI, AZW3).\nHWPX writer보다 위에 두면 전자책 PDF도 만들어요(긴 책은 훨씬 빨라요).", "engine.libreoffice.description": "DOC, PPT, Pages, Keynote, Numbers, 워드퍼펙트, ClarisWorks, Publisher, Visio 같은 오래되었거나 드문 형식을 읽어요.\nDOC·XLS·PPT·ODT·ODP, 오픈도큐먼트 서식 파일, EMF로 저장도 해요.", + "engine.image-ocr.description": "스캔된 PDF 및 이미지에서 텍스트를 추출하여 편집 가능한 문서(Word, 텍스트, HWPX)로 변환합니다.", "profile.images": "이미지", "profile.pdf": "PDF", @@ -534,6 +535,14 @@ "presets.leave_out_pictures": "그림 빼기 (텍스트만)", "presets.leave_out_pictures_hint": "끄면: 그림을 마크다운 파일 옆 \"이름_files\" 폴더에 저장하고 파일에서 연결해요.\n켜면: 폴더 없이 텍스트만 남겨요.", "presets.target_folder": "폴더 (압축 풀기)", + "presets.group.ocr": "광학 문자 인식 (OCR)", + "presets.ocr_enabled": "스캔 및 이미지에서 텍스트 인식", + "presets.ocr_hint": "스캔된 페이지와 이미지에서 편집 가능한 텍스트를 추출하여 Word, 텍스트 및 문서로 변환합니다.", + "presets.ocr_language": "인식 언어", + "presets.ocr_language_hint": "문자와 단어를 정확하게 인식하는 데 사용되는 언어입니다.", + "presets.ocr_language.auto": "자동 / 시스템 기본값", + "presets.ocr_engine_required": "Tesseract가 필요합니다. 설정 → 엔진에서 설치해 주세요", + "presets.ocr_disabled_linux": "Linux에는 내장 OCR이 없습니다. 활성화하려면 설정 → 엔진에서 Tesseract를 설치하세요.", "presets.group.media": "동영상·오디오", "presets.media_quality": "품질", "presets.media_quality_hint": "새 형식에 코덱을 그대로 담을 수 있으면(아이폰 MOV → MP4 등) 다시 인코딩하지 않아요.\n화질과 HDR이 그대로고 몇 초면 끝나요.\n'작게'는 용량을 줄이려고 항상 다시 인코딩해요.", @@ -669,6 +678,11 @@ "engines.guidance.linux.libreoffice": "패키지 관리자로 설치(Ubuntu/Debian: sudo apt install libreoffice)한 뒤 “다시 확인”을 누르세요.\nHWP를 열려면 Java와 H2Orestart 확장도 필요해요.", "engines.guidance.linux.ghostscript": "패키지 관리자로 설치(Ubuntu/Debian: sudo apt install ghostscript)한 뒤 “다시 확인”을 누르세요.", "engines.guidance.windows": "이 PC용 {0} 다운로드는 없어요.\n“내 프로그램 쓰기…”로 설치된 프로그램을 선택해 주세요.", + "engines.package.tesseract.name": "Tesseract (다국어 텍스트 인식)", + "engines.package.tesseract.description": "데바나가리(네팔어/힌디어), 아랍어 및 희귀 문자를 포함한 100개 이상의 언어로 텍스트를 인식합니다.\nmacOS에서는 Apple Vision이 내장되어 영어, CJK 및 유럽어는 추가 설치 없이 동작합니다.", + "engines.guidance.macos.tesseract": "Homebrew로 Tesseract를 설치(brew install tesseract tesseract-lang)한 뒤 “다시 확인”을 선택하세요.\n또는 “내 사본 사용…”을 선택하고 bin 폴더를 지정하세요.", + "engines.guidance.linux.tesseract": "패키지 관리자로 Tesseract를 설치(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)한 뒤 “다시 확인”을 선택하세요.\n또는 “내 사본 사용…”을 선택하고 프로그램 폴더를 지정하세요.", + "engines.guidance.windows.tesseract": "UB-Mannheim(github.com/UB-Mannheim/tesseract)에서 Tesseract를 설치한 뒤 “다시 확인”을 선택하거나 설치 폴더를 지정하세요.", "engines.use_own_app_folder": "{0}.app이 들어 있는 폴더 선택", "engines.progress_detail": "{0} / {1} · {2}/s · {3}", "engines.eta.estimating": "남은 시간 계산 중…", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 2e2da36b6..15e82d6b2 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -147,6 +147,7 @@ "engine.ghostscript.description": "EPS, PostScript i starsze pliki Illustratora do PDF i obrazów; PDF do EPS i PS.", "engine.calibre.description": "Rzadkie formaty e-booków (LIT, LRF, CHM, PDB, RB…) i zapis dla Kindle (MOBI, AZW3).\nUmieszczony nad HWPX writer tworzy też PDF-y e-booków (znacznie szybciej przy długich książkach).", "engine.libreoffice.description": "Odczytuje starsze i rzadsze formaty: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nZapisuje też jako DOC, XLS, PPT, ODT, ODP, szablony OpenDocument i EMF.", + "engine.image-ocr.description": "Wyodrębnia tekst ze skanowanych plików PDF i obrazów do formatu edytowalnych dokumentów (Word, tekst, HWPX).", "profile.images": "Obrazy", "profile.pdf": "PDF", @@ -534,6 +535,14 @@ "presets.leave_out_pictures": "Pomiń obrazy (tylko tekst)", "presets.leave_out_pictures_hint": "Wyłączone: obrazy trafiają do folderu „nazwa_files” obok pliku Markdown, który do nich linkuje.\nWłączone: tylko tekst, bez folderu.", "presets.target_folder": "Folder (rozpakuj archiwa)", + "presets.group.ocr": "Optyczne rozpoznawanie znaków (OCR)", + "presets.ocr_enabled": "Rozpoznaj tekst w skanach i obrazach", + "presets.ocr_hint": "Wyodrębnia edytowalny tekst ze skanowanych stron i obrazów do formatu Word, tekstu i dokumentów.", + "presets.ocr_language": "Język rozpoznawania", + "presets.ocr_language_hint": "Język używany do dokładnego rozpoznawania znaków i słów.", + "presets.ocr_language.auto": "Automatycznie / Domyślny systemu", + "presets.ocr_engine_required": "Wymaga Tesseract. Zainstaluj w Ustawienia → Silniki", + "presets.ocr_disabled_linux": "System Linux nie ma wbudowanego OCR. Zainstaluj Tesseract w Ustawienia → Silniki, aby włączyć.", "presets.group.media": "Wideo i audio", "presets.media_quality": "Jakość", "presets.media_quality_hint": "Gdy nowy format może przyjąć kodeki bez zmian (MOV z iPhone’a → MP4), wideo jest tylko przepakowywane.\nBez utraty jakości, z zachowaniem HDR, w kilka sekund.\nMała zawsze koduje ponownie, by zmniejszyć plik.", @@ -669,6 +678,11 @@ "engines.guidance.linux.libreoffice": "Zainstaluj menedżerem pakietów (Ubuntu/Debian: sudo apt install libreoffice), potem wybierz „Sprawdź ponownie”.\nOtwieranie HWP wymaga też Javy i rozszerzenia H2Orestart.", "engines.guidance.linux.ghostscript": "Zainstaluj menedżerem pakietów (Ubuntu/Debian: sudo apt install ghostscript), potem wybierz „Sprawdź ponownie”.", "engines.guidance.windows": "Filee nie ma do pobrania {0} dla tego komputera.\nWybierz „Użyj mojej kopii…” i wskaż zainstalowaną kopię.", + "engines.package.tesseract.name": "Tesseract (wielojęzyczne rozpoznawanie tekstu)", + "engines.package.tesseract.description": "Rozpoznaje tekst w ponad 100 językach, w tym dewanagari (nepalski/hindi), arabskim i rzadkich pismach.\nNa systemie macOS Apple Vision działa natywnie dla języka angielskiego, CJK i języków europejskich bez tego pakietu.", + "engines.guidance.macos.tesseract": "Zainstaluj Tesseract za pomocą Homebrew (brew install tesseract tesseract-lang), a następnie wybierz „Sprawdź ponownie”.\nLub wybierz „Użyj mojej kopii…” i wskaż folder bin.", + "engines.guidance.linux.tesseract": "Zainstaluj Tesseract za pomocą menedżera pakietów (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), a następnie wybierz „Sprawdź ponownie”.\nLub wybierz „Użyj mojej kopii…” i wskaż folder programu.", + "engines.guidance.windows.tesseract": "Zainstaluj Tesseract z UB-Mannheim (github.com/UB-Mannheim/tesseract), a następnie wybierz „Sprawdź ponownie” lub wskaż folder instalacji.", "engines.use_own_app_folder": "Wybierz folder zawierający {0}.app", "engines.progress_detail": "{0} z {1} · {2}/s · {3}", "engines.eta.estimating": "szacowanie pozostałego czasu…", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 0b7cd7cd0..ad32a2856 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -147,6 +147,7 @@ "engine.ghostscript.description": "EPS, PostScript e arquivos antigos do Illustrator para PDF e imagens; PDF para EPS e PS.", "engine.calibre.description": "Formatos raros de e-book (LIT, LRF, CHM, PDB, RB…) e saída Kindle (MOBI, AZW3).\nAcima do gravador de HWPX, também gera os PDFs de e-books (bem mais rápido em livros longos).", "engine.libreoffice.description": "Lê formatos antigos e menos comuns: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nTambém salva como DOC, XLS, PPT, ODT, ODP, modelos OpenDocument e EMF.", + "engine.image-ocr.description": "Extrai texto de PDFs digitalizados e imagens para documentos editáveis (Word, texto, HWPX).", "profile.images": "Imagens", "profile.pdf": "PDF", @@ -534,6 +535,14 @@ "presets.leave_out_pictures": "Deixar as imagens de fora (só texto)", "presets.leave_out_pictures_hint": "Desligado: as imagens são salvas numa pasta “nome_files” ao lado do arquivo Markdown e referenciadas nele.\nLigado: só o texto, sem pasta.", "presets.target_folder": "Pasta (extrair arquivos compactados)", + "presets.group.ocr": "Reconhecimento Óptico de Caracteres (OCR)", + "presets.ocr_enabled": "Reconhecer texto em digitalizações e imagens", + "presets.ocr_hint": "Extrai texto editável de páginas digitalizadas e imagens para Word, texto e documentos.", + "presets.ocr_language": "Idioma de reconhecimento", + "presets.ocr_language_hint": "Idioma usado para reconhecer caracteres e palavras com precisão.", + "presets.ocr_language.auto": "Automático / Padrão do sistema", + "presets.ocr_engine_required": "Requer Tesseract. Instale em Configurações → Motores", + "presets.ocr_disabled_linux": "O Linux não possui OCR integrado. Instale o Tesseract em Configurações → Motores para ativar.", "presets.group.media": "Vídeo e áudio", "presets.media_quality": "Qualidade", "presets.media_quality_hint": "Quando o novo formato pode guardar os codecs como estão (MOV do iPhone → MP4), o vídeo só é reempacotado.\nSem perda de qualidade, HDR mantido, pronto em segundos.\nPequeno sempre recodifica para deixar o arquivo menor.", @@ -669,6 +678,11 @@ "engines.guidance.linux.libreoffice": "Instale com o gerenciador de pacotes (Ubuntu/Debian: sudo apt install libreoffice) e escolha “Verificar de novo”.\nAbrir HWP também precisa de Java e da extensão H2Orestart.", "engines.guidance.linux.ghostscript": "Instale com o gerenciador de pacotes (Ubuntu/Debian: sudo apt install ghostscript) e escolha “Verificar de novo”.", "engines.guidance.windows": "O Filee não tem download de {0} para este PC.\nEscolha “Usar minha cópia…” e selecione uma cópia instalada.", + "engines.package.tesseract.name": "Tesseract (reconhecimento de texto multilíngue)", + "engines.package.tesseract.description": "Reconhece texto em mais de 100 idiomas, incluindo devanágari (nepalês/híndi), árabe e alfabetos raros.\nNo macOS, o Apple Vision funciona nativamente para inglês, CJK e idiomas europeus sem ele.", + "engines.guidance.macos.tesseract": "Instale o Tesseract com o Homebrew (brew install tesseract tesseract-lang) e selecione “Verificar novamente”.\nOu escolha “Usar minha cópia…” e selecione a pasta bin.", + "engines.guidance.linux.tesseract": "Instale o Tesseract com o gerenciador de pacotes (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all) e selecione “Verificar novamente”.\nOu escolha “Usar minha cópia…” e selecione a pasta do programa.", + "engines.guidance.windows.tesseract": "Instale o Tesseract do UB-Mannheim (github.com/UB-Mannheim/tesseract) e selecione “Verificar novamente” ou selecione sua pasta de instalação.", "engines.use_own_app_folder": "Escolha a pasta que contém {0}.app", "engines.progress_detail": "{0} de {1} · {2}/s · {3}", "engines.eta.estimating": "estimando o tempo restante…", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 3eb526c74..a12e691ec 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -147,6 +147,7 @@ "engine.ghostscript.description": "EPS, PostScript и старые файлы Illustrator — в PDF и картинки; PDF — в EPS и PS.", "engine.calibre.description": "Редкие форматы электронных книг (LIT, LRF, CHM, PDB, RB…) и вывод для Kindle (MOBI, AZW3).\nЕсли стоит выше HWPX writer, он же делает PDF из электронных книг (для длинных книг намного быстрее).", "engine.libreoffice.description": "Читает старые и редкие форматы: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nА ещё сохраняет в DOC, XLS, PPT, ODT, ODP, шаблоны OpenDocument и EMF.", + "engine.image-ocr.description": "Извлекает текст из отсканированных PDF и изображений в редактируемые документы (Word, текст, HWPX).", "profile.images": "Изображения", "profile.pdf": "PDF", @@ -534,6 +535,14 @@ "presets.leave_out_pictures": "Без картинок (только текст)", "presets.leave_out_pictures_hint": "Выключено: картинки сохраняются в папку «имя_files» рядом с файлом Markdown и связаны с ним ссылками.\nВключено: только текст, без папки.", "presets.target_folder": "Папка (распаковка архивов)", + "presets.group.ocr": "Оптическое распознавание символов (OCR)", + "presets.ocr_enabled": "Распознавать текст в сканах и изображениях", + "presets.ocr_hint": "Извлекает редактируемый текст из отсканированных страниц и изображений в Word, текст и документы.", + "presets.ocr_language": "Язык распознавания", + "presets.ocr_language_hint": "Язык для точного распознавания символов и слов.", + "presets.ocr_language.auto": "Автоматически / По умолчанию", + "presets.ocr_engine_required": "Требуется Tesseract. Установите его в Настройки → Движки", + "presets.ocr_disabled_linux": "В Linux нет встроенного OCR. Установите Tesseract в Настройки → Движки, чтобы включить.", "presets.group.media": "Видео и аудио", "presets.media_quality": "Качество", "presets.media_quality_hint": "Если новый формат может принять кодеки как есть (MOV с iPhone → MP4), видео только переупаковывается.\nБез потери качества, HDR сохраняется, за секунды.\n«Компактное» всегда перекодирует, чтобы уменьшить файл.", @@ -669,6 +678,11 @@ "engines.guidance.linux.libreoffice": "Установите менеджером пакетов (Ubuntu/Debian: sudo apt install libreoffice), затем выберите «Проверить снова».\nДля открытия HWP также нужны Java и расширение H2Orestart.", "engines.guidance.linux.ghostscript": "Установите менеджером пакетов (Ubuntu/Debian: sudo apt install ghostscript), затем выберите «Проверить снова».", "engines.guidance.windows": "Filee не может скачать {0} для этого компьютера.\nВыберите «Использовать свою копию…» и укажите установленную копию.", + "engines.package.tesseract.name": "Tesseract (многоязычное распознавание текста)", + "engines.package.tesseract.description": "Распознает текст на более чем 100 языках, включая деванагари (непальский/хинди), арабский и редкие письменности.\nВ macOS Apple Vision работает нативно для английского, CJK и европейских языков без него.", + "engines.guidance.macos.tesseract": "Установите Tesseract с помощью Homebrew (brew install tesseract tesseract-lang), затем выберите «Проверить снова».\nИли выберите «Использовать мою копию…» и укажите папку bin.", + "engines.guidance.linux.tesseract": "Установите Tesseract с помощью пакетного менеджера (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), затем выберите «Проверить снова».\nИли выберите «Использовать мою копию…» и укажите папку программы.", + "engines.guidance.windows.tesseract": "Установите Tesseract из UB-Mannheim (github.com/UB-Mannheim/tesseract), затем выберите «Проверить снова» или укажите папку установки.", "engines.use_own_app_folder": "Выберите папку, в которой лежит {0}.app", "engines.progress_detail": "{0} из {1} · {2}/с · {3}", "engines.eta.estimating": "оцениваем, сколько осталось…", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 6247a2b0a..e33426284 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -147,6 +147,7 @@ "engine.ghostscript.description": "EPS、PostScript 和旧版 Illustrator 文件转为 PDF 和图片;PDF 转为 EPS 和 PS。", "engine.calibre.description": "少见的电子书格式(LIT、LRF、CHM、PDB、RB…)以及 Kindle 输出(MOBI、AZW3)。\n排在 HWPX writer 之上时,也由它把电子书转为 PDF(长书快得多)。", "engine.libreoffice.description": "读取较旧或少见的格式,如 DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visio。\n也能保存为 DOC、XLS、PPT、ODT、ODP、OpenDocument 模板或 EMF。", + "engine.image-ocr.description": "从扫描 PDF 和图片中提取文字并转换为可编辑文档(Word、文本、HWPX)。", "profile.images": "图片", "profile.pdf": "PDF", @@ -534,6 +535,14 @@ "presets.leave_out_pictures": "不保留图片(仅文本)", "presets.leave_out_pictures_hint": "关闭时,图片保存在 Markdown 文件旁的“名称_files”文件夹中,并从文件中链接。\n开启时,只保留文字,不生成文件夹。", "presets.target_folder": "文件夹(解压缩)", + "presets.group.ocr": "光学字符识别 (OCR)", + "presets.ocr_enabled": "识别扫描件和图像中的文本", + "presets.ocr_hint": "从扫描页面和图像中提取可编辑文本并转换为 Word、文本及文档。", + "presets.ocr_language": "识别语言", + "presets.ocr_language_hint": "用于准确识别字符和文字的语言。", + "presets.ocr_language.auto": "自动 / 系统默认", + "presets.ocr_engine_required": "需要 Tesseract。请在“设置 → 转换引擎”中安装", + "presets.ocr_disabled_linux": "Linux 没有内置 OCR。请在“设置 → 转换引擎”中安装 Tesseract 以启用。", "presets.group.media": "视频和音频", "presets.media_quality": "质量", "presets.media_quality_hint": "新格式能直接容纳原编码时(如 iPhone MOV → MP4),只重新封装。\n画质无损、保留 HDR,几秒即可完成。\n“小”总会重新编码以减小文件。", @@ -669,6 +678,11 @@ "engines.guidance.linux.libreoffice": "用软件包管理器安装(Ubuntu/Debian 下运行 sudo apt install libreoffice)后点“重新检查”。\n打开 HWP 还需要 Java 和 H2Orestart 扩展。", "engines.guidance.linux.ghostscript": "用软件包管理器安装(Ubuntu/Debian 下运行 sudo apt install ghostscript)后点“重新检查”。", "engines.guidance.windows": "没有适用于这台电脑的 {0} 下载。\n点“使用我的程序…”选择已安装的程序。", + "engines.package.tesseract.name": "Tesseract(多语言文字识别)", + "engines.package.tesseract.description": "支持梵文(尼泊尔语/印地语)、阿拉伯语等 100 多种语言的文字识别。\n在 macOS 上,系统内置 Apple Vision 可直接识别英语、中日韩及主要欧洲语言。", + "engines.guidance.macos.tesseract": "使用 Homebrew 安装 Tesseract(brew install tesseract tesseract-lang),然后选择“重新检查”。\n或选择“使用我的副本…”并指定其 bin 文件夹。", + "engines.guidance.linux.tesseract": "使用包管理器安装 Tesseract(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all),然后选择“重新检查”。\n或选择“使用我的副本…”并指定其程序文件夹。", + "engines.guidance.windows.tesseract": "从 UB-Mannheim (github.com/UB-Mannheim/tesseract) 安装 Tesseract,然后选择“重新检查”或指定其安装文件夹。", "engines.use_own_app_folder": "选择包含 {0}.app 的文件夹", "engines.progress_detail": "{0} / {1} · {2}/s · {3}", "engines.eta.estimating": "正在估算剩余时间…", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index f06ce0dc7..de8a4e875 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -4,6 +4,7 @@ using Filee.App.ViewModels; using Filee.Core.Conversion; using Filee.Core.Localization; +using Filee.Core.Ocr; using Filee.Core.Platform; using Filee.Core.Settings; using Filee.Core.Web; @@ -48,6 +49,8 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore services.AddSingleton(); // Conversion engines + if (OperatingSystem.IsMacOS()) + OcrEngineRegistry.Register(new MacOsVisionOcrEngine()); services.AddSingleton(_ => new EngineEnvironment(dataDir)); services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(sp => new ConverterCatalog(sp.GetRequiredService>())); diff --git a/src/Filee.App/Services/EngineDownloadService.cs b/src/Filee.App/Services/EngineDownloadService.cs index 2bd410562..14cd8cace 100644 --- a/src/Filee.App/Services/EngineDownloadService.cs +++ b/src/Filee.App/Services/EngineDownloadService.cs @@ -44,7 +44,9 @@ public EnginePackageState(EnginePackage package, ILocalizer loc) public string Sizes => EngineDownloads.CanDownload(Package) ? _loc.Format("engines.package.size", FormatBytes(EngineDownloads.DownloadSize(Package)), FormatBytes(Package.InstalledSize)) - : _loc["engines.package.system_required"]; + : CanSystemInstall(Package) + ? _loc.Format("engines.package.size", FormatBytes(350_000_000), FormatBytes(Package.InstalledSize)) + : _loc["engines.package.system_required"]; /// /// How to get an engine Filee doesn't download on this system (FFmpeg and Ghostscript from Homebrew on macOS, @@ -54,7 +56,7 @@ public string? InstallGuidance { get { - if (EngineDownloads.CanDownload(Package)) + if (EngineDownloads.CanDownload(Package) || CanSystemInstall(Package)) return null; var system = OperatingSystem.IsMacOS() ? "macos" : OperatingSystem.IsLinux() ? "linux" : "windows"; var key = $"engines.guidance.{system}.{Package.Id}"; @@ -114,7 +116,7 @@ public string? InstallGuidance /// The engine is there: Filee's download or the user's own copy. public bool IsInstalled => Status is EnginePackageStatus.Installed or EnginePackageStatus.OwnCopy; - public bool CanRemove => Status == EnginePackageStatus.Installed; + public bool CanRemove => Status == EnginePackageStatus.Installed || (Package.Id == "tesseract" && Status == EnginePackageStatus.OwnCopy && FromSystem); /// The user chose this copy ("Stop using it" applies). public bool UsesOwnCopy => Status == EnginePackageStatus.OwnCopy && !FromSystem; public bool ShowsCopyFolder => Status == EnginePackageStatus.OwnCopy; @@ -122,8 +124,33 @@ public string? InstallGuidance || (Status == EnginePackageStatus.OwnCopy && FromSystem); public bool IsBusy => Status is EnginePackageStatus.Queued or EnginePackageStatus.Downloading or EnginePackageStatus.Unpacking; /// Also when a copy on the PC is used: Filee's download is the tested version and wins once installed. - public bool CanInstall => EngineDownloads.CanDownload(Package) && (Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed - || (Status == EnginePackageStatus.OwnCopy && FromSystem)); + public bool CanInstall => (EngineDownloads.CanDownload(Package) && (Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed || (Status == EnginePackageStatus.OwnCopy && FromSystem))) + || (CanSystemInstall(Package) && Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed); + + /// + /// Whether the app can drive an in-place install of on this machine: Homebrew on + /// macOS, winget on Windows. Linux has no handler here (package managers vary too much to drive one + /// generically), so it keeps the same guidance-text + "Use my copy…" flow as every other engine. + /// + internal static bool CanSystemInstall(EnginePackage package) => + package.Id == "tesseract" && (OperatingSystem.IsMacOS() ? LocateBrew() is not null : OperatingSystem.IsWindows()); + + /// Homebrew's own binary, at its two standard prefixes or on PATH; null when it isn't installed. + internal static string? LocateBrew() + { + if (File.Exists("/opt/homebrew/bin/brew")) return "/opt/homebrew/bin/brew"; + if (File.Exists("/usr/local/bin/brew")) return "/usr/local/bin/brew"; + var path = Environment.GetEnvironmentVariable("PATH"); + if (path is not null) + { + foreach (var part in path.Split(':')) + { + var candidate = Path.Combine(part, "brew"); + if (File.Exists(candidate)) return candidate; + } + } + return null; + } public string StatusText => Status switch { @@ -258,32 +285,40 @@ public async Task InstallAsync(EnginePackageState state) await _oneAtATime.WaitAsync(cancellation.Token); try { - // Progress reports on the UI thread it was created on. - var clock = Stopwatch.StartNew(); - var rate = new TransferRate(); - var progress = new Progress(p => + if (state.Package.Id == "tesseract" && !EngineDownloads.CanDownload(state.Package)) { - if (!state.IsBusy) - return; - state.Status = p.Stage == EngineInstallStage.Downloading ? EnginePackageStatus.Downloading : EnginePackageStatus.Unpacking; - state.Progress = p.Fraction * 100; - if (p.Stage == EngineInstallStage.Downloading && p.BytesTotal > 0) - { - rate.Add(clock.Elapsed, p.BytesDone); - state.ShowTransfer(p.BytesDone, p.BytesTotal, rate); - } - else + await InstallTesseractSystemAsync(state, cancellation.Token); + } + else + { + // Progress reports on the UI thread it was created on. + var clock = Stopwatch.StartNew(); + var rate = new TransferRate(); + var progress = new Progress(p => { - state.ProgressDetail = ""; - } - }); - await Task.Run(() => _installer.InstallAsync(state.Package, progress, cancellation.Token), cancellation.Token); + if (!state.IsBusy) + return; + state.Status = p.Stage == EngineInstallStage.Downloading ? EnginePackageStatus.Downloading : EnginePackageStatus.Unpacking; + state.Progress = p.Fraction * 100; + if (p.Stage == EngineInstallStage.Downloading && p.BytesTotal > 0) + { + rate.Add(clock.Elapsed, p.BytesDone); + state.ShowTransfer(p.BytesDone, p.BytesTotal, rate); + } + else + { + state.ProgressDetail = ""; + } + }); + await Task.Run(() => _installer.InstallAsync(state.Package, progress, cancellation.Token), cancellation.Token); + } } finally { _oneAtATime.Release(); } - state.Status = EnginePackageStatus.Installed; + EngineEnvironment.ForgetSystemCopies(); + state.Status = StatusOf(state); _catalog.Refresh(); if (state.Package.ConverterIds.Contains("libreoffice")) WarmUpInBackground(TimeSpan.Zero); @@ -305,6 +340,142 @@ public async Task InstallAsync(EnginePackageState state) } } + private static async Task InstallTesseractSystemAsync(EnginePackageState state, CancellationToken cancellationToken) + { + try + { + if (OperatingSystem.IsMacOS()) + { + var brew = EnginePackageState.LocateBrew(); + if (brew is null) + throw new InvalidOperationException("Homebrew was not found. Install Homebrew from https://brew.sh first."); + + state.Status = EnginePackageStatus.Downloading; + state.Progress = 5; + state.ProgressDetail = "Starting Homebrew install..."; + + var psi = new ProcessStartInfo + { + FileName = brew, + ArgumentList = { "install", "tesseract", "tesseract-lang" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + var brewDir = Path.GetDirectoryName(brew); + var currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + if (brewDir is not null && !currentPath.Contains(brewDir)) + psi.Environment["PATH"] = brewDir + ":" + currentPath; + psi.Environment["HOMEBREW_NO_AUTO_UPDATE"] = "1"; + psi.Environment["HOMEBREW_NO_ENV_HINTS"] = "1"; + + using var process = new Process { StartInfo = psi }; + process.OutputDataReceived += (_, e) => + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + Dispatcher.UIThread.Post(() => + { + var line = e.Data.Trim(); + state.ProgressDetail = line; + if (line.StartsWith("==> Downloading", StringComparison.OrdinalIgnoreCase)) + { + state.Status = EnginePackageStatus.Downloading; + if (state.Progress < 50) state.Progress += 10; + } + else if (line.StartsWith("==> Pouring", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("==> Installing", StringComparison.OrdinalIgnoreCase)) + { + state.Status = EnginePackageStatus.Unpacking; + if (state.Progress < 90) state.Progress += 10; + } + }); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + Dispatcher.UIThread.Post(() => + { + state.ProgressDetail = e.Data.Trim(); + }); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using (cancellationToken.Register(() => + { + try { process.Kill(true); } catch { } + })) + { + await process.WaitForExitAsync(cancellationToken); + } + + if (process.ExitCode != 0) + throw new InvalidOperationException($"Homebrew install failed with exit code {process.ExitCode}."); + + state.Progress = 100; + state.ProgressDetail = ""; + } + else if (OperatingSystem.IsWindows()) + { + state.Status = EnginePackageStatus.Downloading; + state.Progress = 10; + state.ProgressDetail = "Installing Tesseract with winget..."; + + var psi = new ProcessStartInfo + { + FileName = "winget", + ArgumentList = { "install", "UB-Mannheim.TesseractOCR", "--accept-source-agreements", "--accept-package-agreements" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = psi }; + process.OutputDataReceived += (_, e) => + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + Dispatcher.UIThread.Post(() => + { + state.ProgressDetail = e.Data.Trim(); + }); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + + using (cancellationToken.Register(() => + { + try { process.Kill(true); } catch { } + })) + { + await process.WaitForExitAsync(cancellationToken); + } + + if (process.ExitCode != 0) + throw new InvalidOperationException($"winget install failed with exit code {process.ExitCode}."); + } + else + { + throw new PlatformNotSupportedException("Automatic installation is not supported on this platform. Please install tesseract using your package manager."); + } + } + finally + { + state.Cancellation = null; + state.ProgressDetail = ""; + } + } + public void Cancel(EnginePackageState state) => state.Cancellation?.Cancel(); /// @@ -346,8 +517,45 @@ public bool Uninstall(EnginePackageState state) return false; try { + if (state.Package.Id == "tesseract") + { + if (OperatingSystem.IsMacOS()) + { + var brew = EnginePackageState.LocateBrew(); + if (brew is not null) + { + var psi = new ProcessStartInfo + { + FileName = brew, + ArgumentList = { "uninstall", "tesseract", "tesseract-lang" }, + UseShellExecute = false, + CreateNoWindow = true + }; + var brewDir = Path.GetDirectoryName(brew); + var currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + if (brewDir is not null && !currentPath.Contains(brewDir)) + psi.Environment["PATH"] = brewDir + ":" + currentPath; + psi.Environment["HOMEBREW_NO_AUTO_UPDATE"] = "1"; + psi.Environment["HOMEBREW_NO_ENV_HINTS"] = "1"; + using var process = Process.Start(psi); + process?.WaitForExit(); + } + } + else if (OperatingSystem.IsWindows()) + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = "winget", + ArgumentList = { "uninstall", "UB-Mannheim.TesseractOCR" }, + UseShellExecute = false, + CreateNoWindow = true + }); + process?.WaitForExit(); + } + EngineEnvironment.ForgetSystemCopies(); + } _installer.Uninstall(state.Package); - state.Status = EnginePackageStatus.NotInstalled; + state.Status = StatusOf(state); _catalog.Refresh(); return true; } diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index d76f638b3..43bc2b5f1 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using Filee.Core.Formats; using Filee.Core.Localization; +using Filee.Core.Ocr; using Filee.Core.Presets; namespace Filee.App.ViewModels; @@ -97,6 +98,94 @@ .. FormatRegistry.Known AnimationSpeeds = [.. ImageOptions.AnimationSpeeds.Append(_edit.Image.PlaybackSpeed).Distinct().Order() .Select(s => new Choice(s, s == 1 ? loc["presets.animation_speed.original"] : SpeedLabel(s)))]; + OcrLanguages = + [ + new Choice(null, loc["presets.ocr_language.auto"]), + // Popular / Primary Languages + new Choice("en", "English"), + new Choice("ne", "Nepali / नेपाली"), + new Choice("hi", "Hindi / हिन्दी"), + new Choice("zh-Hans", "Chinese Simplified / 简体中文"), + new Choice("zh-Hant", "Chinese Traditional / 繁體中文"), + new Choice("ja", "Japanese / 日本語"), + new Choice("ko", "Korean / 한국어"), + new Choice("es", "Spanish / Español"), + new Choice("fr", "French / Français"), + new Choice("de", "German / Deutsch"), + new Choice("it", "Italian / Italiano"), + new Choice("pt", "Portuguese / Português"), + new Choice("ru", "Russian / Русский"), + new Choice("ar", "Arabic / العربية"), + new Choice("th", "Thai / ไทย"), + new Choice("el", "Greek / Ελληνικά"), + new Choice("uk", "Ukrainian / Українська"), + new Choice("tr", "Turkish / Türkçe"), + new Choice("vi", "Vietnamese / Tiếng Việt"), + new Choice("id", "Indonesian / Bahasa Indonesia"), + new Choice("pl", "Polish / Polski"), + new Choice("nl", "Dutch / Nederlands"), + new Choice("sv", "Swedish / Svenska"), + new Choice("da", "Danish / Dansk"), + new Choice("fi", "Finnish / Suomi"), + new Choice("no", "Norwegian / Norsk"), + new Choice("cs", "Czech / Čeština"), + new Choice("sk", "Slovak / Slovenčina"), + new Choice("hu", "Hungarian / Magyar"), + new Choice("ro", "Romanian / Română"), + new Choice("bg", "Bulgarian / Български"), + new Choice("hr", "Croatian / Hrvatski"), + new Choice("sr-Cyrl", "Serbian (Cyrillic) / Српски"), + new Choice("sr-Latn", "Serbian (Latin) / Srpski"), + new Choice("sl", "Slovenian / Slovenščina"), + new Choice("et", "Estonian / Eesti"), + new Choice("lv", "Latvian / Latviešu"), + new Choice("lt", "Lithuanian / Lietuvių"), + new Choice("be", "Belarusian / Беларуская"), + // South Asian / Indic Languages + new Choice("sa", "Sanskrit / संस्कृतम्"), + new Choice("mr", "Marathi / मराठी"), + new Choice("ta", "Tamil / தமிழ்"), + new Choice("te", "Telugu / తెలుగు"), + new Choice("ur", "Urdu / اردو"), + new Choice("fa", "Persian / فارسی"), + new Choice("ps", "Pashto / پښتو"), + new Choice("sd", "Sindhi / سنڌي"), + new Choice("ug", "Uyghur / ئۇيغۇرچە"), + // European, Regional & World Languages + new Choice("af", "Afrikaans"), + new Choice("az", "Azerbaijani / Azərbaycan"), + new Choice("bs", "Bosnian / Bosanski"), + new Choice("ca", "Catalan / Català"), + new Choice("cy", "Welsh / Cymraeg"), + new Choice("eu", "Basque / Euskara"), + new Choice("ga", "Irish / Gaeilge"), + new Choice("gl", "Galician / Galego"), + new Choice("is", "Icelandic / Íslenska"), + new Choice("ka", "Georgian / ქართული"), + new Choice("ku", "Kurdish / Kurdî"), + new Choice("la", "Latin / Latina"), + new Choice("lb", "Luxembourgish / Lëtzebuergesch"), + new Choice("mi", "Maori / Te Reo Māori"), + new Choice("mk", "Macedonian / Македонски"), + new Choice("mn", "Mongolian / Монгол"), + new Choice("ms", "Malay / Bahasa Melayu"), + new Choice("mt", "Maltese / Malti"), + new Choice("oc", "Occitan / Lenga d'òc"), + new Choice("qu", "Quechua / Runasimi"), + new Choice("sq", "Albanian / Shqip"), + new Choice("sw", "Swahili / Kiswahili"), + new Choice("tl", "Tagalog / Filipino"), + new Choice("uz", "Uzbek / Oʻzbekcha"), + new Choice("kk", "Kazakh / Қазақша"), + new Choice("ky", "Kyrgyz / Кыргызча"), + new Choice("tg", "Tajik / Тоҷикӣ"), + new Choice("tt", "Tatar / Татарча"), + ]; + // Vision and Tesseract each claim a real, limited set of languages (SupportedLanguages); the old PaddleOCR + // engine claimed over 100 it could never actually recognize (no bundled model, no install path). Only offer + // what a registered engine can really attempt, so picking a language never leads straight to "engine missing". + OcrLanguages = [.. OcrLanguages.Where(c => c.Value is null || OcrEngineRegistry.AnyEngineSupports(c.Value))]; + _name = string.IsNullOrWhiteSpace(_edit.Name) ? loc.DisplayName(_edit) : _edit.Name; _target = Targets.FirstOrDefault(t => t.Value == _edit.TargetFormat) ?? Targets[1]; _resize = ResizeModes.First(r => r.Value == _edit.Image.Resize); @@ -163,6 +252,13 @@ .. FormatRegistry.Known _pattern = _edit.Output.FileNamePattern; _keepDates = _edit.Output.KeepDates; _zipResult = _edit.Output.ZipResult; + _ocr = _edit.Document.Ocr; + _selectedOcrLanguage = OcrLanguages.FirstOrDefault(l => + l.Value == _edit.Document.OcrLanguage || + (l.Value is not null && _edit.Document.OcrLanguage is not null && + (_edit.Document.OcrLanguage.StartsWith(l.Value, StringComparison.OrdinalIgnoreCase) || + l.Value.StartsWith(_edit.Document.OcrLanguage, StringComparison.OrdinalIgnoreCase)))) + ?? OcrLanguages[0]; } public string PresetId => _original.Id; @@ -193,6 +289,7 @@ .. FormatRegistry.Known /// Playback speeds of animations, 0.25× to 4× (ImageOptions.AnimationSpeed). public IReadOnlyList> AnimationSpeeds { get; } + public IReadOnlyList> OcrLanguages { get; } [ObservableProperty, NotifyPropertyChangedFor(nameof(NameError), nameof(IsValid))] private string _name; @@ -206,7 +303,8 @@ .. FormatRegistry.Known nameof(AudioCodecs), nameof(AudioCodecChoice), nameof(ShowAudioCodec), nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), nameof(ShowSaveEveryFrame), nameof(ShowAnimationSpeed), nameof(ShowTrace), nameof(ShowExtractMotionPhoto), - nameof(ShowLivePhotoChoice), nameof(LivePhotoKinds), nameof(LivePhotoChoice), nameof(IsLivePhoto), nameof(LivePhotoHint))] + nameof(ShowLivePhotoChoice), nameof(LivePhotoKinds), nameof(LivePhotoChoice), nameof(IsLivePhoto), nameof(LivePhotoHint), + nameof(ShowOcr), nameof(ShowOcrLanguage), nameof(ShowOcrMissingWarning), nameof(OcrDisabledOnLinux))] private Choice _target; private LivePhotoKind _livePhotoValue; @@ -393,6 +491,8 @@ public bool ColorProfileChecked /// Markdown: the text only, no picture folder next to it (DocumentOptions.LeaveOutPictures). [ObservableProperty] private bool _leaveOutPictures; + [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowOcrLanguage)), NotifyPropertyChangedFor(nameof(ShowOcrMissingWarning))] private bool _ocr; + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasOcrEngine)), NotifyPropertyChangedFor(nameof(ShowOcrMissingWarning))] private Choice _selectedOcrLanguage; [ObservableProperty] private string _subfolderName; /// The subfolder only for a file that becomes several (OutputRule.SubfolderOnlyForSeveralFiles). [ObservableProperty] private bool _subfolderOnlyForSeveralFiles; @@ -497,6 +597,17 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne public bool ShowDocument => TargetId == "pdf"; /// Markdown can't hold pictures: they go into a folder next to it, or are left out. public bool ShowLeaveOutPictures => TargetId == "md"; + + /// OCR (Optical Character Recognition) for text recognition in scanned documents / images. + public bool ShowOcr => !IsTool && (TargetCategory is FormatCategory.Document or FormatCategory.Text or FormatCategory.Ebook + || TargetId is "docx" or "txt" or "md" or "hwpx" or "pdf" or "rtf" or "odt"); + public bool ShowOcrLanguage => ShowOcr && Ocr; + public bool HasOcrEngine => OcrEngineRegistry.GetDefault(SelectedOcrLanguage?.Value) is not null; + public bool HasAnyOcrEngine => OcrEngineRegistry.All.Any(e => e.IsAvailable); + /// Linux has no built-in OCR (unlike Apple Vision on macOS), so Tesseract isn't just optional there: with + /// nothing installed, OCR can't run at all, and the toggle is disabled rather than left on to fail at conversion time. + public bool OcrDisabledOnLinux => ShowOcr && OperatingSystem.IsLinux() && !HasAnyOcrEngine; + public bool ShowOcrMissingWarning => ShowOcr && !OcrDisabledOnLinux && (!HasAnyOcrEngine || (Ocr && !HasOcrEngine)); public bool ShowMedia => ShowMediaQuality || ShowMaxHeight || ShowRemoveAudio || ShowAudioChannels || ShowVideoCodec || ShowAudioCodec; /// Mono / stereo for every audio target but AMR, which is mono by definition. public bool ShowAudioChannels => TargetIsAudio && TargetId != "amr"; @@ -598,6 +709,8 @@ public bool Apply() _original.Document.PdfA = PdfA; // Only where the option is shown, as with SaveEveryFrame below. _original.Document.LeaveOutPictures = ShowLeaveOutPictures && LeaveOutPictures; + _original.Document.Ocr = ShowOcr && Ocr; + _original.Document.OcrLanguage = ShowOcr && Ocr ? SelectedOcrLanguage.Value : null; var media = _original.Media; media.Quality = MediaQuality.Value; diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 8c81df875..de37f45a7 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -296,6 +296,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Filee.Cli/CliHost.cs b/src/Filee.Cli/CliHost.cs index 14bb8b43e..dae34da3e 100644 --- a/src/Filee.Cli/CliHost.cs +++ b/src/Filee.Cli/CliHost.cs @@ -34,6 +34,8 @@ public static CliHost Create(string? dataDirectory = null) EngineEnvironment.LibreOfficeForDocuments = store.Settings.LibreOfficeForDocuments; EngineEnvironment.LibreOfficeForSheets = store.Settings.LibreOfficeForSheets; var converters = EngineRegistry.CreateAll(new EngineEnvironment(store.Directory)); + if (OperatingSystem.IsMacOS()) + Filee.Core.Ocr.OcrEngineRegistry.Register(new Filee.Platform.MacOS.MacOsVisionOcrEngine()); var catalog = new ConverterCatalog(converters) { Priority = store.Settings.EnginePriority }; var queue = new JobQueue(catalog, EngineRegistry.FindPdfMerger(converters), combiner: EngineRegistry.FindFileCombiner(converters), tiffMerger: EngineRegistry.FindTiffMerger(converters)); diff --git a/src/Filee.Cli/Filee.Cli.csproj b/src/Filee.Cli/Filee.Cli.csproj index af728be3a..1883ea6d1 100644 --- a/src/Filee.Cli/Filee.Cli.csproj +++ b/src/Filee.Cli/Filee.Cli.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Filee.Core/Ocr/IOcrEngine.cs b/src/Filee.Core/Ocr/IOcrEngine.cs new file mode 100644 index 000000000..4f320abdd --- /dev/null +++ b/src/Filee.Core/Ocr/IOcrEngine.cs @@ -0,0 +1,30 @@ +// The contract every OCR engine implements (Apple Vision, Tesseract): recognize text and word boxes in an image, +// so callers like PdfDocumentReader can place the result back onto the page without knowing which engine ran. + +namespace Filee.Core.Ocr; + +/// +/// A service that extracts text and bounding boxes from images. +/// +public interface IOcrEngine +{ + /// Unique engine identifier (e.g. "vision", "tesseract"). + string Id { get; } + + /// Display name (e.g. "Apple Vision", "Tesseract OCR"). + string DisplayName { get; } + + /// Whether this engine is available and ready on the current machine. + bool IsAvailable { get; } + + /// Supported languages (BCP-47 codes, or empty if generic/all). + IReadOnlyList SupportedLanguages { get; } + + /// + /// Recognizes text in an image file. + /// + /// Path to an image file (PNG, JPG, TIFF, etc.). + /// Optional language hint or code. + /// Cancellation token. + Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default); +} diff --git a/src/Filee.Core/Ocr/OcrEngineRegistry.cs b/src/Filee.Core/Ocr/OcrEngineRegistry.cs new file mode 100644 index 000000000..504bdf7de --- /dev/null +++ b/src/Filee.Core/Ocr/OcrEngineRegistry.cs @@ -0,0 +1,135 @@ +// Where callers (PdfDocumentReader, ImageOcrConverter, the preset editor) find an OCR engine without depending on +// Filee.Platform.MacOS or Filee.Engines directly: Tesseract registers itself through EngineRegistry.CreateAll on +// every platform, Apple Vision is discovered lazily and by reflection since it lives in a macOS-only assembly. + +namespace Filee.Core.Ocr; + +/// +/// Registry of available OCR engines. +/// +public static class OcrEngineRegistry +{ + private static readonly List RegisteredEngines = []; + private static readonly Lock Sync = new(); + + public static void Register(IOcrEngine engine) + { + ArgumentNullException.ThrowIfNull(engine); + lock (Sync) + { + if (!RegisteredEngines.Any(e => e.Id.Equals(engine.Id, StringComparison.OrdinalIgnoreCase))) + { + RegisteredEngines.Add(engine); + } + } + } + + public static IReadOnlyList All + { + get + { + lock (Sync) + { + DiscoverNativeEngine(); + return [.. RegisteredEngines]; + } + } + } + + /// + /// The engine to use. With a specific , only an engine whose + /// actually covers it is returned — never an engine that would + /// silently misread a script it doesn't know (e.g. Apple Vision guessing at Devanagari), even if that engine is + /// otherwise the platform's native/preferred one. With no language ("Automatic"), the platform's native engine + /// (Apple Vision on macOS: zero install, GPU/NPU accelerated) is preferred, since the document's script isn't + /// known ahead of time; picking a non-Latin-only language from the preset editor is what routes to Tesseract. + /// + public static IOcrEngine? GetDefault(string? language = null) + { + lock (Sync) + { + DiscoverNativeEngine(); + + if (!string.IsNullOrWhiteSpace(language)) + return RegisteredEngines.FirstOrDefault(e => e.IsAvailable && Supports(e, language)); + + var native = RegisteredEngines.FirstOrDefault(e => e.Id.Equals("vision", StringComparison.OrdinalIgnoreCase) && e.IsAvailable); + return native ?? RegisteredEngines.FirstOrDefault(e => e.IsAvailable); + } + } + + /// + /// The exact code to pass to 's for Filee's + /// code: Apple Vision needs its own full BCP-47 entry ("en" → "en-US"), engines that + /// map Filee's codes themselves (Tesseract) get the code unchanged. + /// + public static string? ResolveLanguage(IOcrEngine? engine, string? language) + { + if (engine is null || string.IsNullOrWhiteSpace(language)) + return language; + return engine.SupportedLanguages.FirstOrDefault(l => + l.Equals(language, StringComparison.OrdinalIgnoreCase) + || l.StartsWith(language + "-", StringComparison.OrdinalIgnoreCase)) ?? language; + } + + /// + /// Whether any registered engine (installed or not) claims to support — for the + /// preset editor's language picker, so it never offers a language no engine could ever attempt. Use + /// to find the engine that can actually run it right now. + /// + public static bool AnyEngineSupports(string language) + { + lock (Sync) + { + DiscoverNativeEngine(); + return RegisteredEngines.Any(e => Supports(e, language)); + } + } + + private static bool Supports(IOcrEngine engine, string language) => + engine.SupportedLanguages.Count == 0 + || engine.SupportedLanguages.Any(l => l.Equals(language, StringComparison.OrdinalIgnoreCase) + || l.StartsWith(language + "-", StringComparison.OrdinalIgnoreCase)); + + /// Registers the platform's native engine (Apple Vision) the first time it's needed. + private static void DiscoverNativeEngine() + { + if (!OperatingSystem.IsMacOS() || RegisteredEngines.Any(e => e.Id.Equals("vision", StringComparison.OrdinalIgnoreCase))) + return; + try + { + var asm = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "Filee.Platform.MacOS"); + if (asm is null) + { + try + { + asm = System.Runtime.Loader.AssemblyLoadContext.Default.LoadFromAssemblyName(new System.Reflection.AssemblyName("Filee.Platform.MacOS")); + } + catch + { + var baseDir = AppContext.BaseDirectory; + var path = System.IO.Path.Combine(baseDir, "Filee.Platform.MacOS.dll"); + if (System.IO.File.Exists(path)) + asm = System.Runtime.Loader.AssemblyLoadContext.Default.LoadFromAssemblyPath(path); + } + } + + var macEngineType = asm?.GetType("Filee.Platform.MacOS.MacOsVisionOcrEngine") + ?? Type.GetType("Filee.Platform.MacOS.MacOsVisionOcrEngine, Filee.Platform.MacOS"); + if (macEngineType is not null && Activator.CreateInstance(macEngineType) is IOcrEngine macEngine && macEngine.IsAvailable) + RegisteredEngines.Add(macEngine); + } + catch + { + // Ignore discovery failure + } + } + + public static IOcrEngine? GetById(string id) + { + lock (Sync) + { + return RegisteredEngines.FirstOrDefault(e => e.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/src/Filee.Core/Ocr/OcrResult.cs b/src/Filee.Core/Ocr/OcrResult.cs new file mode 100644 index 000000000..2d60ac93c --- /dev/null +++ b/src/Filee.Core/Ocr/OcrResult.cs @@ -0,0 +1,39 @@ +// The result of an IOcrEngine.RecognizeAsync call: text and boxes in image pixel coordinates, grouped into words +// and lines so a caller can either read the plain text or reconstruct layout (PdfDocumentReader scales these boxes +// back onto the PDF page, one shared baseline per line rather than per word). + +namespace Filee.Core.Ocr; + +/// +/// A bounding box in image pixel coordinates (Left/Top to Right/Bottom). +/// +public readonly record struct OcrRect(double Left, double Top, double Right, double Bottom) +{ + public double Width => Math.Max(0, Right - Left); + public double Height => Math.Max(0, Bottom - Top); +} + +/// +/// A recognized word with its text, bounding box, and recognition confidence. +/// +public sealed record OcrWord(string Text, OcrRect Box, float Confidence = 1.0f); + +/// +/// A recognized line composed of words. +/// +public sealed record OcrLine(string Text, IReadOnlyList Words, OcrRect Box); + +/// +/// The complete OCR result of an image or page. +/// +public sealed record OcrPageResult( + string Text, + IReadOnlyList Lines, + IReadOnlyList Words, + int ImageWidth, + int ImageHeight, + string? Language = null) +{ + public static OcrPageResult Empty(int width = 0, int height = 0) => + new("", [], [], width, height); +} diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index ed6315102..eada26a57 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -423,6 +423,16 @@ public sealed class DocumentOptions /// public bool LeaveOutPictures { get; set; } + /// + /// Optical Character Recognition for scanned pages and images. + /// + public bool Ocr { get; set; } = true; + + /// + /// Optical Character Recognition language code (e.g. "en-US", "ko-KR"). Null uses the system default. + /// + public string? OcrLanguage { get; set; } + public DocumentOptions Clone() => (DocumentOptions)MemberwiseClone(); } diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs index b9c0f0102..facebb673 100644 --- a/src/Filee.Engines/EngineRegistry.cs +++ b/src/Filee.Engines/EngineRegistry.cs @@ -1,6 +1,7 @@ // THE place where conversion engines are registered. Adding an engine = one line in CreateAll. using Filee.Core.Conversion; +using Filee.Core.Ocr; using Filee.Engines.Archives; using Filee.Engines.Cad; using Filee.Engines.Data; @@ -15,6 +16,7 @@ using Filee.Engines.Magick; using Filee.Engines.Media; using Filee.Engines.Models3D; +using Filee.Engines.Ocr; using Filee.Engines.Office; using Filee.Engines.Pdf; using Filee.Engines.Subtitles; @@ -37,38 +39,44 @@ public static class EngineRegistry /// The app's log; FFmpeg writes which hardware encoders work and when one failed, LibreOffice when a file needed /// its container repacked. /// - public static IReadOnlyList CreateAll(EngineEnvironment env, ILoggerFactory? loggers = null) => - [ - new MagickImageConverter(), - new PdfSharpConverter(), - new PdfiumConverter(), - new RhwpConverter(), - new UnhwpConverter(), - new MarkdownConverter(), - new SpreadsheetConverter(), - new OoxmlConverter(), - new HwpxConverter(), - new ArchiveConverter(), - new VectorConverter(), - new IcnsConverter(), - new FontConverter(), - new DocxWriterConverter(), - new PdfTextConverter(), - new PdfSlidesConverter(), - new EmlConverter(), - new DataConverter(), - new SubtitleConverter(), - new WicImageConverter(), - new FfmpegConverter(loggers?.CreateLogger()), - new CadConverter(), - new ModelConverter(), - new GeoConverter(), - new EbookConverter(), - new PandocConverter(), - new GhostscriptConverter(), - new CalibreConverter(), - new LibreOfficeConverter(env, loggers?.CreateLogger()), - ]; + public static IReadOnlyList CreateAll(EngineEnvironment env, ILoggerFactory? loggers = null) + { + OcrEngineRegistry.Register(new TesseractOcrEngine()); + + return + [ + new MagickImageConverter(), + new PdfSharpConverter(), + new PdfiumConverter(), + new RhwpConverter(), + new UnhwpConverter(), + new MarkdownConverter(), + new SpreadsheetConverter(), + new OoxmlConverter(), + new HwpxConverter(), + new ArchiveConverter(), + new VectorConverter(), + new IcnsConverter(), + new FontConverter(), + new DocxWriterConverter(), + new ImageOcrConverter(), + new PdfTextConverter(), + new PdfSlidesConverter(), + new EmlConverter(), + new DataConverter(), + new SubtitleConverter(), + new WicImageConverter(), + new FfmpegConverter(loggers?.CreateLogger()), + new CadConverter(), + new ModelConverter(), + new GeoConverter(), + new EbookConverter(), + new PandocConverter(), + new GhostscriptConverter(), + new CalibreConverter(), + new LibreOfficeConverter(env, loggers?.CreateLogger()), + ]; + } /// Finds the PDF merger among the converters. public static IPdfMerger? FindPdfMerger(IEnumerable converters) => diff --git a/src/Filee.Engines/Filee.Engines.csproj b/src/Filee.Engines/Filee.Engines.csproj index ca144acd8..1c3d7420d 100644 --- a/src/Filee.Engines/Filee.Engines.csproj +++ b/src/Filee.Engines/Filee.Engines.csproj @@ -39,6 +39,7 @@ + diff --git a/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs b/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs index b98e78d0d..2920b6556 100644 --- a/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs +++ b/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json.Nodes; +using Filee.Core.Presets; using Filee.Engines.Ebooks; using Filee.Engines.Email; using Filee.Engines.Hwp.Hwpx.Docx; @@ -105,9 +106,15 @@ internal static class DocumentReaders /// Reads (of format ) into the document model. /// Scratch folder of the job: extracted pictures go into a new folder inside it. - public static async Task ReadAsync(string path, string format, string workDirectory, CancellationToken cancellationToken) + public static async Task ReadAsync(string path, string format, string workDirectory, CancellationToken cancellationToken, Preset? preset = null) { var media = Path.Combine(workDirectory, $"media-{Guid.NewGuid():N}"); + if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase)) + { + var enableOcr = preset?.Document.Ocr ?? true; + var ocrLang = preset?.Document.OcrLanguage; + return await Task.Run(() => PdfDocumentReader.Read(path, media, null, ocrLang, enableOcr), cancellationToken); + } if (BuiltIn.TryGetValue(format, out var reader)) return await Task.Run(() => reader(path, media), cancellationToken); if (ThroughPandoc.TryGetValue(format, out var pandocReader)) diff --git a/src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs b/src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs index bf6f570eb..fdaa1d195 100644 --- a/src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs +++ b/src/Filee.Engines/Hwp/Hwpx/HwpxConverter.cs @@ -45,7 +45,7 @@ public EngineStatus GetStatus() public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { progress?.Report(0.1); - var document = await DocumentReaders.ReadAsync(step.InputPath, step.From, step.WorkDirectory, cancellationToken); + var document = await DocumentReaders.ReadAsync(step.InputPath, step.From, step.WorkDirectory, cancellationToken, step.Preset); progress?.Report(0.6); var output = step.Output.Allocate("hwpx"); diff --git a/src/Filee.Engines/Infrastructure/EngineDownloads.cs b/src/Filee.Engines/Infrastructure/EngineDownloads.cs index 7ae3e3022..4a1506842 100644 --- a/src/Filee.Engines/Infrastructure/EngineDownloads.cs +++ b/src/Filee.Engines/Infrastructure/EngineDownloads.cs @@ -44,12 +44,17 @@ public static class EngineDownloads new("ghostscript", OperatingSystem.IsWindows() ? ["ghostscript", "vcruntime"] : ["ghostscript"], 31_000_000, ["ghostscript"]), new("ffmpeg", ["ffmpeg"], 272_000_000, ["ffmpeg"]), new("calibre", ["calibre"], 663_000_000, ["calibre"]), + // No Filee-hosted download on any platform yet (Tesseract's Linux/macOS builds are dynamically linked + // against leptonica/libpng/etc., not xcopy-deployable the way FFmpeg's static builds are): install it with + // the OS's own package manager or installer, then "Use my copy…", same as Ghostscript on macOS and Linux. + new("tesseract", [], 30_000_000, ["image-ocr"]), ]; /// Total download size of a package in bytes. public static long DownloadSize(EnginePackage package) => package.Components.Sum(c => Components.GetValueOrDefault(c)?.Size ?? 0); - public static bool CanDownload(EnginePackage package) => package.Components.Count > 0 && package.Components.All(Components.ContainsKey); + public static bool CanDownload(EnginePackage package) => + package.Components.Count > 0 && package.Components.All(Components.ContainsKey); public static string? UnavailableReason(EnginePackage package) { @@ -61,6 +66,7 @@ public static class EngineDownloads "ffmpeg" => "Install FFmpeg with Homebrew (brew install ffmpeg), then check again or select its bin folder.", "ghostscript" => "Install Ghostscript with Homebrew (brew install ghostscript), then check again or select its bin folder.", "calibre" => "The calibre Filee downloads needs macOS 14 or later. Install calibre 7.26 from calibre-ebook.com and select its .app folder.", + "tesseract" => "Install Tesseract with Homebrew (brew install tesseract tesseract-lang for every language), then check again or select its bin folder. Apple Vision already recognizes English, CJK and a few other languages without it.", _ => $"No Filee download is available for {RuntimeIdentifier}. Install {package.Id} for this Mac and select its program or .app folder.", }; if (OperatingSystem.IsLinux()) @@ -68,8 +74,11 @@ public static class EngineDownloads { "libreoffice" => "Install LibreOffice using your distribution's package manager (Ubuntu/Debian: sudo apt install libreoffice), then check again. HWP import through LibreOffice also needs Java and the H2Orestart extension.", "ghostscript" => "Install Ghostscript using your distribution's package manager (Ubuntu/Debian: sudo apt install ghostscript), then check again.", + "tesseract" => "Install Tesseract using your distribution's package manager (Ubuntu/Debian: sudo apt install tesseract-ocr, plus tesseract-ocr- for each language, e.g. tesseract-ocr-nep), then check again. Linux has no built-in OCR, so this is required for OCR to work at all.", _ => $"No Filee download is available for {RuntimeIdentifier}. Install {package.Id} using your distribution's package manager and select its program folder.", }; + if (package.Id == "tesseract") + return "Install Tesseract from https://github.com/UB-Mannheim/tesseract (ticking the languages you need), then check again or select its install folder."; return $"No Filee download is available for {RuntimeIdentifier}. Select a compatible installed copy."; } diff --git a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs index a94979e60..12a9da9e4 100644 --- a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs +++ b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs @@ -84,6 +84,7 @@ dataDirectoryOverride is not null ["calibre"] = [windows ? "ebook-convert.exe" : "ebook-convert"], ["rhwp"] = [windows ? "rhwp.exe" : "rhwp"], ["7zip"] = [windows ? "7z.exe" : "7zz"], + ["tesseract"] = [windows ? "tesseract.exe" : "tesseract"], }; public static string ProgramName(string name) => OperatingSystem.IsWindows() ? name @@ -217,6 +218,8 @@ private static IEnumerable SystemFolders(string id) } if (id == "calibre") folders.AddRange(["/Applications/calibre.app", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", "calibre.app"), "/opt/calibre"]); + if (id == "tesseract") + folders.AddRange(["/opt/homebrew/opt/tesseract/bin", "/usr/local/opt/tesseract/bin"]); } foreach (var root in (OperatingSystem.IsWindows() ? new[] { Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolder.ProgramFilesX86 } @@ -231,6 +234,10 @@ private static IEnumerable SystemFolders(string id) case "calibre": folders.Add(Path.Combine(root, "Calibre2")); break; + case "tesseract": + // The official UB-Mannheim installer's default location. + folders.Add(Path.Combine(root, "Tesseract-OCR")); + break; case "ghostscript": // C:\Program Files\gs\gs10.04.0\bin, newest first try diff --git a/src/Filee.Engines/Infrastructure/EngineInstaller.cs b/src/Filee.Engines/Infrastructure/EngineInstaller.cs index 12fe5fac3..ec2cdc70b 100644 --- a/src/Filee.Engines/Infrastructure/EngineInstaller.cs +++ b/src/Filee.Engines/Infrastructure/EngineInstaller.cs @@ -53,11 +53,11 @@ public EngineInstaller(HttpClient http, string? root = null, IReadOnlyDictionary } /// True when every component is installed (any version). - public bool IsInstalled(EnginePackage package) => package.Components.All(c => _components.ContainsKey(c) && Marker(c) is not null); + public bool IsInstalled(EnginePackage package) => package.Components.Count > 0 && package.Components.All(c => _components.ContainsKey(c) && Marker(c) is not null); /// True when every component is installed in the version engines.json pins. public bool IsUpToDate(EnginePackage package) => - package.Components.All(c => _components.TryGetValue(c, out var component) + package.Components.Count > 0 && package.Components.All(c => _components.TryGetValue(c, out var component) && string.Equals(Marker(c), component.Sha256, StringComparison.OrdinalIgnoreCase)); /// Downloads and unpacks the components that are missing or outdated. diff --git a/src/Filee.Engines/Ocr/ImageOcrConverter.cs b/src/Filee.Engines/Ocr/ImageOcrConverter.cs new file mode 100644 index 000000000..64e2d217a --- /dev/null +++ b/src/Filee.Engines/Ocr/ImageOcrConverter.cs @@ -0,0 +1,96 @@ +// Image → TXT, DOCX and Markdown via whichever OCR engine OcrEngineRegistry resolves (Apple Vision on macOS, +// Tesseract everywhere else). Allows dropping scans, photos or screenshots directly and extracting editable text. + +using System.Text; +using Filee.Core.Conversion; +using Filee.Core.Ocr; +using Filee.Engines.Hwp.Hwpx; +using Filee.Engines.Hwp.Hwpx.Docx; +using Filee.Engines.Infrastructure; + +namespace Filee.Engines.Ocr; + +/// +/// Converts raster images directly to text documents using Optical Character Recognition. +/// +public sealed class ImageOcrConverter : IConverter +{ + private static readonly string[] ImageFormats = ["png", "jpg", "jpeg", "webp", "tiff", "bmp"]; + + public string Id => "image-ocr"; + public string DisplayName => "Image OCR (built-in)"; + public int MaxParallelism => 0; + + public IReadOnlyList Edges { get; } = + ImageFormats.SelectMany(from => new ConversionEdge[] + { + new(from, "txt", 15, DirectOnly: true), + new(from, "docx", 15, DirectOnly: true), + new(from, "md", 15, DirectOnly: true), + }).ToList(); + + public EngineStatus GetStatus() + { + var engine = OcrEngineRegistry.GetDefault(); + if (engine is { IsAvailable: true }) + return EngineStatus.Available($"Image → TXT, DOCX, Markdown ({engine.DisplayName})", EngineVersions.BuiltIn); + + return EngineStatus.Unavailable("engine.reason.not_installed", "No supported OCR engine found on this system."); + } + + public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + { + if (!step.Preset.Document.Ocr) + throw new InvalidOperationException("OCR is disabled in preset settings."); + + var language = step.Preset.Document.OcrLanguage; + var engine = OcrEngineRegistry.GetDefault(language); + if (engine is null || !engine.IsAvailable) + throw new InvalidOperationException(string.IsNullOrWhiteSpace(language) + ? "No OCR engine is installed. Install Tesseract in Settings → Engines." + : $"No installed OCR engine recognizes '{language}'. Install Tesseract with that language's trained data in Settings → Engines."); + + progress?.Report(0.1); + var result = await engine.RecognizeAsync(step.InputPath, OcrEngineRegistry.ResolveLanguage(engine, language), cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + progress?.Report(0.7); + + if (string.IsNullOrWhiteSpace(result.Text)) + throw new InvalidOperationException("No text could be recognized in the image."); + + var output = step.Output.Allocate(step.To); + if (output is null) + return []; + + switch (step.To) + { + case "txt": + await File.WriteAllTextAsync(output, result.Text, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), cancellationToken); + break; + + case "md": + var mdContent = string.Join("\n\n", result.Lines.Select(l => l.Text)); + await File.WriteAllTextAsync(output, mdContent, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); + break; + + case "docx": + var doc = new HDocument(); + var section = new HSection(); + foreach (var line in result.Lines) + { + var p = new HParagraph(); + p.Inlines.Add(new HText(line.Text, default)); + section.Blocks.Add(p); + } + doc.Sections.Add(section); + await Task.Run(() => DocxWriter.Write(doc, output), cancellationToken); + break; + + default: + throw new NotSupportedException($"Target format {step.To} is not supported by Image OCR."); + } + + progress?.Report(1.0); + return [output]; + } +} diff --git a/src/Filee.Engines/Ocr/TesseractOcrEngine.cs b/src/Filee.Engines/Ocr/TesseractOcrEngine.cs new file mode 100644 index 000000000..2baea8ce3 --- /dev/null +++ b/src/Filee.Engines/Ocr/TesseractOcrEngine.cs @@ -0,0 +1,243 @@ +// Tesseract OCR engine: a single self-contained binary plus pinned .traineddata files, found the same way as every +// other optional engine (OwnCopies, Filee's own copy, then one already on the PC). The real cross-platform, no-Python +// fallback for platforms without a native OCR API (Windows, Linux) and for scripts Apple Vision doesn't recognize. + +using System.Globalization; +using Filee.Core.Ocr; +using Filee.Engines.Infrastructure; + +namespace Filee.Engines.Ocr; + +/// +/// Optical Character Recognition engine powered by Tesseract (Apache-2.0), run as a subprocess. Covers every +/// platform and, with the matching .traineddata installed, scripts Apple Vision does not (Devanagari, Cyrillic +/// beyond Russian/Ukrainian, Thai, Greek, Arabic script, ...). +/// +public sealed class TesseractOcrEngine : IOcrEngine +{ + private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2); + + public string Id => "tesseract"; + public string DisplayName => "Tesseract OCR"; + + public bool IsAvailable => LocateExecutable() is not null; + + /// + /// Filee's language codes this engine can be asked to recognize, limited to what Tesseract's own trained-data + /// set actually ships (tessdata_fast/best): asking for a code outside this list always fails, so the UI and + /// only offer what can really work, instead of PaddleOCR's old claimed-but-never + /// bundled 100+ languages. + /// + public IReadOnlyList SupportedLanguages => [.. LanguageMap.Keys]; + + /// Filee language code (the OCR language picker, BCP-47-ish) → Tesseract's own 3-letter code. + private static readonly IReadOnlyDictionary LanguageMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["en"] = "eng", + ["ne"] = "nep", + ["hi"] = "hin", + ["zh-Hans"] = "chi_sim", + ["zh-Hant"] = "chi_tra", + ["ja"] = "jpn", + ["ko"] = "kor", + ["es"] = "spa", + ["fr"] = "fra", + ["de"] = "deu", + ["it"] = "ita", + ["pt"] = "por", + ["ru"] = "rus", + ["ar"] = "ara", + ["th"] = "tha", + ["el"] = "ell", + ["uk"] = "ukr", + ["tr"] = "tur", + ["vi"] = "vie", + ["id"] = "ind", + ["pl"] = "pol", + ["nl"] = "nld", + ["sv"] = "swe", + ["da"] = "dan", + ["fi"] = "fin", + ["no"] = "nor", + ["cs"] = "ces", + ["sk"] = "slk", + ["hu"] = "hun", + ["ro"] = "ron", + ["bg"] = "bul", + ["hr"] = "hrv", + ["sr-Cyrl"] = "srp", + ["sr-Latn"] = "srp_latn", + ["sl"] = "slv", + ["et"] = "est", + ["lv"] = "lav", + ["lt"] = "lit", + ["be"] = "bel", + ["sa"] = "san", + ["mr"] = "mar", + ["ta"] = "tam", + ["te"] = "tel", + ["ur"] = "urd", + ["fa"] = "fas", + ["ps"] = "pus", + ["sd"] = "snd", + ["ug"] = "uig", + ["af"] = "afr", + ["az"] = "aze", + ["bs"] = "bos", + ["ca"] = "cat", + ["cy"] = "cym", + ["eu"] = "eus", + ["ga"] = "gle", + ["gl"] = "glg", + ["is"] = "isl", + ["ka"] = "kat", + ["ku"] = "kmr", + ["la"] = "lat", + ["lb"] = "ltz", + ["mi"] = "mri", + ["mk"] = "mkd", + ["mn"] = "mon", + ["ms"] = "msa", + ["mt"] = "mlt", + ["oc"] = "oci", + ["qu"] = "que", + ["sq"] = "sqi", + ["sw"] = "swa", + ["tl"] = "fil", + ["uz"] = "uzb", + ["kk"] = "kaz", + ["ky"] = "kir", + ["tg"] = "tgk", + ["tt"] = "tat", + }; + + /// Tesseract's own code for a Filee language code, or null when Tesseract has no matching trained data. + public static string? MapLanguage(string? language) + { + if (string.IsNullOrWhiteSpace(language)) + return "eng"; + return LanguageMap.TryGetValue(language.Trim(), out var code) ? code : null; + } + + public static string? LocateExecutable() => + EngineEnvironment.OwnProgram("tesseract", "tesseract.exe") + ?? EngineEnvironment.BundledProgram("tesseract", "tesseract.exe") + ?? EngineEnvironment.SystemProgram("tesseract", "tesseract.exe"); + + /// + /// The tessdata folder next to : Homebrew, apt and the official Windows installer + /// all keep trained-data files at one of these relative locations. Null lets tesseract fall back to its own + /// compiled-in default (TESSDATA_PREFIX or the path it was built with), same as running it from a shell. + /// + public static string? LocateTessdataDir(string executable) + { + var bin = Path.GetDirectoryName(executable); + if (bin is null) + return null; + var root = Path.GetFullPath(Path.Combine(bin, "..")); + string[] candidates = + [ + Path.Combine(bin, "tessdata"), + Path.Combine(root, "share", "tessdata"), + Path.Combine(root, "share", "tesseract-ocr", "5", "tessdata"), + Path.Combine(root, "share", "tesseract-ocr", "4.00", "tessdata"), + "/usr/share/tesseract-ocr/5/tessdata", + "/usr/share/tesseract-ocr/4.00/tessdata", + "/usr/share/tessdata", + ]; + return candidates.FirstOrDefault(c => Directory.Exists(c) && Directory.EnumerateFiles(c, "*.traineddata").Any()); + } + + /// Whether has its .traineddata installed, not just a known mapping. + public bool HasLanguageData(string? language) + { + var exe = LocateExecutable(); + var code = MapLanguage(language); + if (exe is null || code is null) + return false; + var dir = LocateTessdataDir(exe); + if (dir is null) + return true; // Can't check; let tesseract itself report a missing language if it turns out to be wrong. + return File.Exists(Path.Combine(dir, $"{code}.traineddata")); + } + + public async Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default) + { + if (!File.Exists(imagePath)) + throw new FileNotFoundException("Image file not found for OCR.", imagePath); + + var exe = LocateExecutable() ?? throw new InvalidOperationException("Tesseract is not installed."); + var code = MapLanguage(language) + ?? throw new InvalidOperationException($"Tesseract has no trained data for language '{language}'."); + + var args = new List { imagePath, "stdout", "-l", code }; + if (LocateTessdataDir(exe) is { } tessdata) + args.AddRange(["--tessdata-dir", tessdata]); + args.Add("tsv"); + + var result = await ProcessRunner.RunAsync(exe, [.. args], Timeout, cancellationToken); + if (result.ExitCode != 0) + { + throw new InvalidOperationException(result.StandardError.Contains("Failed loading language", StringComparison.OrdinalIgnoreCase) + ? $"Tesseract has no trained data installed for '{code}'. Install the {language} language pack and try again." + : $"Tesseract could not read the image. {result.StandardError.Trim()}"); + } + + return ParseTsv(result.StandardOutput, code); + } + + private static OcrPageResult ParseTsv(string tsv, string language) + { + var words = new List(); + var lineGroups = new List<(string Key, List Words)>(); + var imgWidth = 0; + var imgHeight = 0; + + foreach (var row in tsv.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var cols = row.Split('\t'); + // level, page_num, block_num, par_num, line_num, word_num, left, top, width, height, conf, text + if (cols.Length < 12) + continue; + if (cols[0] == "1") + { + // Page-level row: left/top/width/height is the whole image, needed to scale word boxes back + // to the source page (PdfDocumentReader) even when the recognized text doesn't fill the image. + int.TryParse(cols[8], out imgWidth); + int.TryParse(cols[9], out imgHeight); + continue; + } + if (cols[0] != "5") + continue; + var text = cols[11].Trim(); + if (text.Length == 0) + continue; + if (!double.TryParse(cols[6], NumberStyles.Float, CultureInfo.InvariantCulture, out var left) + || !double.TryParse(cols[7], NumberStyles.Float, CultureInfo.InvariantCulture, out var top) + || !double.TryParse(cols[8], NumberStyles.Float, CultureInfo.InvariantCulture, out var width) + || !double.TryParse(cols[9], NumberStyles.Float, CultureInfo.InvariantCulture, out var height)) + continue; + float.TryParse(cols[10], NumberStyles.Float, CultureInfo.InvariantCulture, out var conf); + + var word = new OcrWord(text, new OcrRect(left, top, left + width, top + height), Math.Max(0, conf) / 100f); + words.Add(word); + + var lineKey = string.Join('.', cols[1], cols[2], cols[3], cols[4]); + var group = lineGroups.Count > 0 && lineGroups[^1].Key == lineKey ? lineGroups[^1] : (lineKey, []); + if (lineGroups.Count == 0 || lineGroups[^1].Key != lineKey) + lineGroups.Add(group); + group.Words.Add(word); + } + + var lines = lineGroups.Select(g => + { + var text = string.Join(' ', g.Words.Select(w => w.Text)); + var box = new OcrRect( + g.Words.Min(w => w.Box.Left), g.Words.Min(w => w.Box.Top), + g.Words.Max(w => w.Box.Right), g.Words.Max(w => w.Box.Bottom)); + return new OcrLine(text, g.Words, box); + }).ToList(); + + return new OcrPageResult(string.Join('\n', lines.Select(l => l.Text)), lines, words, imgWidth, imgHeight, language); + } +} diff --git a/src/Filee.Engines/Office/DocxWriterConverter.cs b/src/Filee.Engines/Office/DocxWriterConverter.cs index 0b2deb234..9fc095ed6 100644 --- a/src/Filee.Engines/Office/DocxWriterConverter.cs +++ b/src/Filee.Engines/Office/DocxWriterConverter.cs @@ -32,7 +32,7 @@ public EngineStatus GetStatus() => public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { progress?.Report(0.1); - var document = await DocumentReaders.ReadAsync(step.InputPath, step.From, step.WorkDirectory, cancellationToken); + var document = await DocumentReaders.ReadAsync(step.InputPath, step.From, step.WorkDirectory, cancellationToken, step.Preset); progress?.Report(0.6); var output = step.Output.Allocate("docx"); diff --git a/src/Filee.Engines/Pdf/PdfDocumentReader.cs b/src/Filee.Engines/Pdf/PdfDocumentReader.cs index cb4a90583..bd67cde4a 100644 --- a/src/Filee.Engines/Pdf/PdfDocumentReader.cs +++ b/src/Filee.Engines/Pdf/PdfDocumentReader.cs @@ -17,6 +17,7 @@ using System.Text; using System.Text.RegularExpressions; +using Filee.Core.Ocr; using Filee.Engines.Hwp.Hwpx; using Filee.Engines.Infrastructure; using PDFtoImage; @@ -102,23 +103,37 @@ internal sealed partial class PdfDocumentReader private readonly string _path; private readonly string _mediaFolder; private readonly bool _textOnly; + private readonly IOcrEngine? _ocrEngine; + private readonly string? _ocrLanguage; + private readonly bool _enableOcr; private byte[]? _pdfBytes; private int _pictures; - private PdfDocumentReader(string path, string mediaFolder, bool textOnly) + private PdfDocumentReader(string path, string mediaFolder, bool textOnly, IOcrEngine? ocrEngine = null, string? ocrLanguage = null, bool enableOcr = true) { _path = path; _mediaFolder = mediaFolder; _textOnly = textOnly; + _ocrEngine = ocrEngine; + _ocrLanguage = ocrLanguage; + _enableOcr = enableOcr; } /// Reads with pictures (extracted into ) and scans. - public static HDocument Read(string path, string mediaFolder) => new PdfDocumentReader(path, mediaFolder, textOnly: false).ReadDocument(); + public static HDocument Read(string path, string mediaFolder) => + new PdfDocumentReader(path, mediaFolder, textOnly: false).ReadDocument(); + + public static HDocument Read(string path, string mediaFolder, IOcrEngine? ocrEngine, string? ocrLanguage = null, bool enableOcr = true) => + new PdfDocumentReader(path, mediaFolder, textOnly: false, ocrEngine, ocrLanguage, enableOcr).ReadDocument(); /// /// Reads only the text, including invisible text (the OCR layer of scanned PDFs); pictures and scans are left out. /// - public static HDocument ReadText(string path) => new PdfDocumentReader(path, "", textOnly: true).ReadDocument(); + public static HDocument ReadText(string path) => + new PdfDocumentReader(path, "", textOnly: true).ReadDocument(); + + public static HDocument ReadText(string path, IOcrEngine? ocrEngine, string? ocrLanguage = null, bool enableOcr = true) => + new PdfDocumentReader(path, "", textOnly: true, ocrEngine, ocrLanguage, enableOcr).ReadDocument(); // ───────────────────────── Model of a page ───────────────────────── @@ -237,7 +252,7 @@ internal static InvalidOperationException PasswordProtected() => new("The PDF is password-protected. Open it with its password and save a copy without one, then convert that copy."); /// - /// The text of each page in (zero-based), read like (reading order, + /// The text of each page in (zero-based), read like (reading order, /// invisible OCR text included) but page by page, with running heads and page numbers kept: "" for a page without /// text. Paragraphs are separated by an empty line, lines end with CR LF. /// @@ -308,13 +323,83 @@ private PageContent ReadPage(Page page) lead.StartBaseLine.Y - crop.Bottom, Math.Max(1, size), Format(lead, size), link)); } + if (words.Count == 0) + { + // First check if the page already had an invisible OCR layer that was skipped for formatting + foreach (var word in pageWords) + { + if (string.IsNullOrWhiteSpace(word.Text)) + continue; + var letters = word.Letters.Where(l => !string.IsNullOrWhiteSpace(l.Value)).ToList(); + if (letters.Count == 0) + continue; + var lead = string.IsNullOrWhiteSpace(word.Lead.Value) ? letters[0] : word.Lead; + var box = Shift(word.Box, crop); + var size = Median(letters.Select(LetterSize)); + var center = new PdfPoint((box.Left + box.Right) / 2, (box.Top + box.Bottom) / 2); + var link = links.FirstOrDefault(l => Inside(l.Bounds, center)).Uri; + words.Add(new TextWord(CleanText(word.Text), box.Left, box.Right, box.Top, box.Bottom, + lead.StartBaseLine.Y - crop.Bottom, Math.Max(1, size), Format(lead, size), link)); + } + } + if (words.Count == 0 || (rotated && !_textOnly)) { - // A scan, a page of drawings, or a turned page: the rendered page keeps everything. - content.Scanned = true; - if (!_textOnly) - content.ScanPath = RenderScan(page.Number - 1); - return content; + if (_enableOcr) + { + var ocr = _ocrEngine ?? OcrEngineRegistry.GetDefault(_ocrLanguage); + if (ocr is not null && ocr.IsAvailable) + { + var scanPath = RenderScan(page.Number - 1); + try + { + var ocrResult = ocr.RecognizeAsync(scanPath, OcrEngineRegistry.ResolveLanguage(ocr, _ocrLanguage)).GetAwaiter().GetResult(); + if (ocrResult.Words.Count > 0) + { + var scaleX = content.Width / (double)ocrResult.ImageWidth; + var scaleY = content.Height / (double)ocrResult.ImageHeight; + // One baseline/top/bottom per OCR-detected line, not per word: on a skewed or photographed + // scan each word's own pixel box drifts slightly in Y, which (once scaled to PDF points) + // is enough for Lines() below to split a single real text line into several interleaved + // reconstructed lines. Tesseract already groups words into lines; use that box. + foreach (var line in ocrResult.Lines) + { + var pdfTop = content.Height - (line.Box.Top * scaleY); + var pdfBottom = content.Height - (line.Box.Bottom * scaleY); + var size = Math.Max(4, pdfTop - pdfBottom); + var format = new HCharFormat(Size: (int)Math.Round(size * 100)); + foreach (var w in line.Words) + { + var pdfLeft = w.Box.Left * scaleX; + var pdfRight = w.Box.Right * scaleX; + words.Add(new TextWord(CleanText(w.Text), pdfLeft, pdfRight, pdfTop, pdfBottom, pdfBottom, size, format, null)); + } + } + } + } + catch + { + // Fall back to pure scan if OCR encounters an error + } + + if (words.Count == 0) + { + content.Scanned = true; + if (!_textOnly) + content.ScanPath = scanPath; + return content; + } + } + } + + if (words.Count == 0) + { + // A scan, a page of drawings, or a turned page: the rendered page keeps everything. + content.Scanned = true; + if (!_textOnly) + content.ScanPath = RenderScan(page.Number - 1); + return content; + } } // Plain text (TXT, page texts for slides) keeps table cells as paragraphs in reading order. @@ -484,8 +569,9 @@ private static bool IsPlainJpeg(IPdfImage image) private string NewMediaPath(string extension) { - Directory.CreateDirectory(_mediaFolder); - return Path.Combine(_mediaFolder, $"pdf-{++_pictures}.{extension}"); + var folder = string.IsNullOrEmpty(_mediaFolder) ? Path.Combine(Path.GetTempPath(), "filee-media") : _mediaFolder; + Directory.CreateDirectory(folder); + return Path.Combine(folder, $"pdf-{++_pictures}.{extension}"); } private SKBitmap Render(int pageIndex) @@ -966,7 +1052,7 @@ private static double Median(IEnumerable values) private static string? NullIfBlank(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim(); - /// Plain text of a document read by : paragraphs separated by an empty line. + /// Plain text of a document read by : paragraphs separated by an empty line. public static string PlainText(HDocument document) { var sb = new StringBuilder(); diff --git a/src/Filee.Engines/Pdf/PdfTextConverter.cs b/src/Filee.Engines/Pdf/PdfTextConverter.cs index 86859ffb4..1588eed22 100644 --- a/src/Filee.Engines/Pdf/PdfTextConverter.cs +++ b/src/Filee.Engines/Pdf/PdfTextConverter.cs @@ -40,7 +40,7 @@ public Task> ConvertAsync(ConversionStep step, IProgress Text(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { progress?.Report(0.1); - var text = PdfDocumentReader.PlainText(PdfDocumentReader.ReadText(step.InputPath)); + var text = PdfDocumentReader.PlainText(PdfDocumentReader.ReadText(step.InputPath, null, step.Preset.Document.OcrLanguage, step.Preset.Document.Ocr)); cancellationToken.ThrowIfCancellationRequested(); if (text.Length == 0) throw NoText(); @@ -59,8 +59,8 @@ private static IReadOnlyList Markdown(ConversionStep step, IProgressOptical Character Recognition engine powered by Apple's Vision framework (macOS only). +[SupportedOSPlatform("macos")] +public sealed class MacOsVisionOcrEngine : IOcrEngine +{ + private static readonly Lazy VisionAvailable = new(() => + { + if (!OperatingSystem.IsMacOS()) + return false; + try + { + NativeLibrary.Load("/System/Library/Frameworks/Vision.framework/Vision"); + return objc_getClass("VNRecognizeTextRequest") != 0; + } + catch + { + return false; + } + }); + + public string Id => "vision"; + + public string DisplayName => "Apple Vision"; + + public bool IsAvailable => VisionAvailable.Value; + + public IReadOnlyList SupportedLanguages { get; } = + [ + "en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR", "ru-RU", "uk-UA" + ]; + + public Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default) + { + if (!IsAvailable) + return Task.FromResult(OcrPageResult.Empty()); + + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + using var pool = new AutoreleasePool(); + + var (width, height) = GetImageDimensions(imagePath); + if (width <= 0 || height <= 0) + (width, height) = (1000, 1000); + + var nsPath = CFStringCreateWithCString(0, imagePath, 0x08000100); + if (nsPath == 0) + return OcrPageResult.Empty(width, height); + + try + { + var urlClass = objc_getClass("NSURL"); + var fileUrl = Send(urlClass, sel_registerName("fileURLWithPath:"), nsPath); + + var reqClass = objc_getClass("VNRecognizeTextRequest"); + var request = Send(Send(reqClass, sel_registerName("alloc")), sel_registerName("init")); + SendInteger(request, sel_registerName("setRecognitionLevel:"), 0); // 0 = VNRequestTextRecognitionLevelAccurate + SendBool(request, sel_registerName("setUsesLanguageCorrection:"), true); + + if (!string.IsNullOrEmpty(language)) + { + var langString = CFStringCreateWithCString(0, language, 0x08000100); + if (langString != 0) + { + var arrayClass = objc_getClass("NSArray"); + var langsArray = Send(arrayClass, sel_registerName("arrayWithObject:"), langString); + Send(request, sel_registerName("setRecognitionLanguages:"), langsArray); + CFRelease(langString); + } + } + + var dictClass = objc_getClass("NSDictionary"); + var emptyDict = Send(dictClass, sel_registerName("dictionary")); + var handlerClass = objc_getClass("VNImageRequestHandler"); + var handler = Send(Send(handlerClass, sel_registerName("alloc")), sel_registerName("initWithURL:options:"), fileUrl, emptyDict); + + var arrayClass2 = objc_getClass("NSArray"); + var requestsArray = Send(arrayClass2, sel_registerName("arrayWithObject:"), request); + + if (!SendPerform(handler, sel_registerName("performRequests:error:"), requestsArray, out _)) + return OcrPageResult.Empty(width, height); + + var results = Send(request, sel_registerName("results")); + if (results == 0) + return OcrPageResult.Empty(width, height); + + var count = (int)CFArrayGetCount(results); + var lines = new List(); + var allWords = new List(); + var fullText = new StringBuilder(); + + for (var i = 0; i < count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var obs = CFArrayGetValueAtIndex(results, i); + var candidates = Send(obs, sel_registerName("topCandidates:"), 1); + if (candidates == 0 || CFArrayGetCount(candidates) == 0) + continue; + + var cand = CFArrayGetValueAtIndex(candidates, 0); + var nsStr = Send(cand, sel_registerName("string")); + var utf8Ptr = Send(nsStr, sel_registerName("UTF8String")); + var text = Marshal.PtrToStringUTF8(utf8Ptr); + if (string.IsNullOrWhiteSpace(text)) + continue; + + var conf = SendFloat(cand, sel_registerName("confidence")); + var normBox = SendRect(obs, sel_registerName("boundingBox")); + + // Apple Vision returns normalized coordinates with bottom-left origin: + // normBox.X (0..1 from left), normBox.Y (0..1 from bottom), + // normBox.Width, normBox.Height. + // Convert to pixel coordinates with top-left origin: + var pixelLeft = normBox.X * width; + var pixelRight = (normBox.X + normBox.Width) * width; + var pixelTop = (1.0 - normBox.Y - normBox.Height) * height; + var pixelBottom = (1.0 - normBox.Y) * height; + var lineBox = new OcrRect(pixelLeft, pixelTop, pixelRight, pixelBottom); + + // Estimate word boxes along the line + var rawWords = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var wordsInLine = new List(); + if (rawWords.Length == 1) + { + var word = new OcrWord(rawWords[0], lineBox, conf); + wordsInLine.Add(word); + allWords.Add(word); + } + else if (rawWords.Length > 1) + { + var totalChars = rawWords.Sum(w => w.Length); + var currentX = pixelLeft; + var usableWidth = Math.Max(0, lineBox.Width); + for (var w = 0; w < rawWords.Length; w++) + { + var wStr = rawWords[w]; + var wWidth = totalChars > 0 ? (usableWidth * wStr.Length / (double)totalChars) : (usableWidth / rawWords.Length); + var wBox = new OcrRect(currentX, pixelTop, currentX + wWidth, pixelBottom); + var word = new OcrWord(wStr, wBox, conf); + wordsInLine.Add(word); + allWords.Add(word); + currentX += wWidth; + } + } + + lines.Add(new OcrLine(text, wordsInLine, lineBox)); + fullText.AppendLine(text); + } + + return new OcrPageResult(fullText.ToString().TrimEnd(), lines, allWords, width, height, language); + } + finally + { + CFRelease(nsPath); + } + }, cancellationToken); + } + + private static (int Width, int Height) GetImageDimensions(string path) + { + try + { + var nsImageClass = objc_getClass("NSImage"); + var nsPath = CFStringCreateWithCString(0, path, 0x08000100); + if (nsPath == 0) + return (0, 0); + try + { + var alloc = Send(nsImageClass, sel_registerName("alloc")); + var image = Send(alloc, sel_registerName("initWithContentsOfFile:"), nsPath); + if (image == 0) + return (0, 0); + var size = SendSize(image, sel_registerName("size")); + return ((int)Math.Round(size.Width), (int)Math.Round(size.Height)); + } + finally + { + CFRelease(nsPath); + } + } + catch + { + return (0, 0); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct CGRect + { + public double X; + public double Y; + public double Width; + public double Height; + } + + private readonly struct AutoreleasePool : IDisposable + { + private readonly nint _pool = Send(Send(objc_getClass("NSAutoreleasePool"), sel_registerName("alloc")), sel_registerName("init")); + public AutoreleasePool() { } + public void Dispose() => SendVoid(_pool, sel_registerName("drain")); + } + + [StructLayout(LayoutKind.Sequential)] + private struct CGSize + { + public double Width; + public double Height; + } + + private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + private const string ObjC = "/usr/lib/libobjc.A.dylib"; + + [DllImport(CoreFoundation)] + private static extern nint CFArrayGetCount(nint array); + + [DllImport(CoreFoundation)] + private static extern nint CFArrayGetValueAtIndex(nint array, nint index); + + [DllImport(CoreFoundation)] + private static extern nint CFStringCreateWithCString(nint allocator, [MarshalAs(UnmanagedType.LPUTF8Str)] string value, uint encoding); + + [DllImport(CoreFoundation)] + private static extern void CFRelease(nint obj); + + [DllImport(ObjC)] + private static extern nint objc_getClass([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC)] + private static extern nint sel_registerName([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector, nint argument); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector, nint arg1, nint arg2); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool SendBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern void SendInteger(nint receiver, nint selector, nint value); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool SendPerform(nint receiver, nint selector, nint requests, out nint error); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern float SendFloat(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern CGRect SendRect(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern CGSize SendSize(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern void SendVoid(nint receiver, nint selector); +} diff --git a/tests/Filee.App.Tests/EnginesPageTests.cs b/tests/Filee.App.Tests/EnginesPageTests.cs index f1c6edec4..dcc491a4f 100644 --- a/tests/Filee.App.Tests/EnginesPageTests.cs +++ b/tests/Filee.App.Tests/EnginesPageTests.cs @@ -111,4 +111,17 @@ public void Only_LibreOffice_has_settings_and_they_change_the_saved_switches() vm.Dispose(); } } + + [AvaloniaFact] + public void Tesseract_package_has_install_available_when_not_installed() + { + TestServices.EnsureInitialized("en"); + var downloads = AppHost.Get(); + var tesseract = downloads.Packages.FirstOrDefault(p => p.Package.Id == "tesseract"); + Assert.NotNull(tesseract); + if (EnginePackageState.CanSystemInstall(tesseract.Package)) + { + Assert.True(tesseract.CanInstall || tesseract.IsInstalled); + } + } } diff --git a/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs b/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs new file mode 100644 index 000000000..1a6f16e64 --- /dev/null +++ b/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs @@ -0,0 +1,88 @@ +using Filee.Core.Ocr; +using Filee.Core.Presets; + +namespace Filee.Core.Tests; + +public class OcrEngineRegistryTests +{ + private sealed class MockOcrEngine(string id, string name, bool available, IReadOnlyList languages) : IOcrEngine + { + public string Id => id; + public string DisplayName => name; + public bool IsAvailable => available; + public IReadOnlyList SupportedLanguages => languages; + + public Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default) + { + var word = new OcrWord("test", new OcrRect(0, 0, 10, 10), 0.99f); + var line = new OcrLine("test", [word], new OcrRect(0, 0, 10, 10)); + return Task.FromResult(new OcrPageResult("test", [line], [word], 100, 100, language)); + } + } + + [Fact] + public void OcrRect_computes_width_and_height_correctly() + { + var rect = new OcrRect(10, 20, 110, 70); + Assert.Equal(100, rect.Width); + Assert.Equal(50, rect.Height); + } + + [Fact] + public void OcrPageResult_Empty_has_zero_words_and_lines() + { + var empty = OcrPageResult.Empty(200, 300); + Assert.Empty(empty.Text); + Assert.Empty(empty.Lines); + Assert.Empty(empty.Words); + Assert.Equal(200, empty.ImageWidth); + Assert.Equal(300, empty.ImageHeight); + } + + [Fact] + public void Register_and_retrieve_engine() + { + var mock = new MockOcrEngine("mock-ocr", "Mock OCR", true, ["en", "ne"]); + OcrEngineRegistry.Register(mock); + + Assert.Contains(OcrEngineRegistry.All, e => e.Id == "mock-ocr"); + Assert.NotNull(OcrEngineRegistry.GetById("mock-ocr")); + Assert.Same(mock, OcrEngineRegistry.GetById("mock-ocr")); + } + + [Fact] + public void DocumentOptions_defaults_and_cloning() + { + var options = new DocumentOptions(); + Assert.True(options.Ocr); + Assert.Null(options.OcrLanguage); + + options.Ocr = false; + options.OcrLanguage = "ne"; + + var clone = options.Clone(); + Assert.False(clone.Ocr); + Assert.Equal("ne", clone.OcrLanguage); + } + + [Fact] + public void GetDefault_routes_a_language_to_the_engine_that_actually_supports_it() + { + // A language no other test's mock claims, so this isn't order-dependent on the shared registry. + var tesseract = new MockOcrEngine("tesseract", "Tesseract OCR", true, ["th", "el"]); + OcrEngineRegistry.Register(tesseract); + + var thaiEngine = OcrEngineRegistry.GetDefault("th"); + Assert.NotNull(thaiEngine); + Assert.Equal("tesseract", thaiEngine.Id); + } + + [Fact] + public void GetDefault_returns_null_when_no_available_engine_supports_the_language() + { + var unrelated = new MockOcrEngine("unrelated-mock", "Unrelated", true, ["fr"]); + OcrEngineRegistry.Register(unrelated); + + Assert.Null(OcrEngineRegistry.GetDefault("a-language-nothing-claims")); + } +} diff --git a/tests/Filee.Engines.Tests/TesseractOcrTests.cs b/tests/Filee.Engines.Tests/TesseractOcrTests.cs new file mode 100644 index 000000000..b5fe537ab --- /dev/null +++ b/tests/Filee.Engines.Tests/TesseractOcrTests.cs @@ -0,0 +1,74 @@ +using Filee.Core.Ocr; +using Filee.Engines.Ocr; +using SkiaSharp; + +namespace Filee.Engines.Tests; + +public sealed class TesseractOcrTests +{ + // Pure mapping logic: runs even where Tesseract itself isn't installed (CI). + [Theory] + [InlineData("en", "eng")] + [InlineData("ne", "nep")] + [InlineData("zh-Hans", "chi_sim")] + [InlineData("zh-Hant", "chi_tra")] + [InlineData(null, "eng")] + public void MapLanguage_maps_known_codes(string? filee, string tesseract) => + Assert.Equal(tesseract, TesseractOcrEngine.MapLanguage(filee)); + + [Fact] + public void MapLanguage_returns_null_for_a_code_with_no_trained_data() + { + // Unlike the old PaddleOCR engine, which silently fell back to English for any code it didn't + // recognize, an unmapped code must be reported as unsupported rather than guessed at. + Assert.Null(TesseractOcrEngine.MapLanguage("xal")); + } + + [Fact] + public async Task RecognizesTextInAGeneratedImage() + { + var engine = new TesseractOcrEngine(); + Assert.SkipUnless(engine.IsAvailable, "Tesseract is not installed on this machine."); + + var imagePath = Path.Combine(Path.GetTempPath(), $"filee-tesseract-test-{Guid.NewGuid():N}.png"); + try + { + using (var bitmap = new SKBitmap(400, 120)) + using (var canvas = new SKCanvas(bitmap)) + using (var font = new SKFont(SKTypeface.Default, 48)) + using (var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true }) + { + canvas.Clear(SKColors.White); + canvas.DrawText("Filee OCR Test", 10, 70, font, paint); + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + using var stream = File.OpenWrite(imagePath); + data.SaveTo(stream); + } + + var result = await engine.RecognizeAsync(imagePath, "en", TestContext.Current.CancellationToken); + + Assert.Contains("Filee", result.Text, StringComparison.OrdinalIgnoreCase); + Assert.NotEmpty(result.Words); + Assert.True(result.ImageWidth > 0 && result.ImageHeight > 0); + } + finally + { + File.Delete(imagePath); + } + } + + [Fact] + public async Task RecognizeAsync_throws_a_clear_error_for_an_unmapped_language() + { + var engine = new TesseractOcrEngine(); + Assert.SkipUnless(engine.IsAvailable, "Tesseract is not installed on this machine."); + + // Any existing file: the unmapped-language check happens before the image is read. + var imagePath = Path.Combine(AppContext.BaseDirectory, "Filee.Engines.Tests.dll"); + + var ex = await Assert.ThrowsAsync(() => + engine.RecognizeAsync(imagePath, "xal", TestContext.Current.CancellationToken)); + Assert.Contains("xal", ex.Message); + } +} diff --git a/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj b/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj index a3d0e81a4..27f0ce7f1 100644 --- a/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj +++ b/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/Filee.Platform.Tests/VisionOcrTests.cs b/tests/Filee.Platform.Tests/VisionOcrTests.cs new file mode 100644 index 000000000..de906b166 --- /dev/null +++ b/tests/Filee.Platform.Tests/VisionOcrTests.cs @@ -0,0 +1,198 @@ +using Filee.Core.Conversion; +using Filee.Core.Ocr; +using Filee.Core.Presets; +using Filee.Engines.Hwp.Hwpx; +using Filee.Engines.Ocr; +using Filee.Engines.Pdf; +using Filee.Platform.MacOS; + +namespace Filee.Platform.Tests; + +public sealed class VisionOcrTests +{ + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Filee.slnx"))) + dir = dir.Parent; + return dir!.FullName; + } + + [Fact] + public async Task VisionOcrRecognizesTextInImageOnMacOS() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS Vision framework."); + return; + } + + var engine = new MacOsVisionOcrEngine(); + Assert.True(engine.IsAvailable); + Assert.Equal("vision", engine.Id); + Assert.NotEmpty(engine.SupportedLanguages); + + var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); + Assert.True(File.Exists(imagePath), $"Test image {imagePath} should exist"); + + var result = await engine.RecognizeAsync(imagePath, "en-US", TestContext.Current.CancellationToken); + + Assert.NotEmpty(result.Text); + Assert.NotEmpty(result.Lines); + Assert.NotEmpty(result.Words); + Assert.True(result.ImageWidth > 0); + Assert.True(result.ImageHeight > 0); + + // The banner image has "Filee" + Assert.Contains("Filee", result.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ImageOcrConverterConvertsImageToTextAndDocx() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS Vision framework."); + return; + } + + var engine = new MacOsVisionOcrEngine(); + OcrEngineRegistry.Register(engine); + + var converter = new ImageOcrConverter(); + Assert.True(converter.GetStatus().IsAvailable); + + var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); + var workDir = Path.Combine(Path.GetTempPath(), $"filee-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + + try + { + var txtOut = Path.Combine(workDir, "banner.txt"); + var txtStep = new ConversionStep( + InputPath: imagePath, + From: "jpg", + To: "txt", + Preset: new Preset(), + Output: new FixedPath(txtOut), + WorkDirectory: workDir); + + var txtOutputs = await converter.ConvertAsync(txtStep, null, TestContext.Current.CancellationToken); + Assert.Single(txtOutputs); + Assert.True(File.Exists(txtOutputs[0])); + var txt = await File.ReadAllTextAsync(txtOutputs[0], TestContext.Current.CancellationToken); + Assert.Contains("Filee", txt, StringComparison.OrdinalIgnoreCase); + + var docxOut = Path.Combine(workDir, "banner.docx"); + var docxStep = new ConversionStep( + InputPath: imagePath, + From: "jpg", + To: "docx", + Preset: new Preset(), + Output: new FixedPath(docxOut), + WorkDirectory: workDir); + + var docxOutputs = await converter.ConvertAsync(docxStep, null, TestContext.Current.CancellationToken); + Assert.Single(docxOutputs); + Assert.True(File.Exists(docxOutputs[0])); + Assert.True(new FileInfo(docxOutputs[0]).Length > 1000); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + + [Fact] + public void PdfDocumentReaderExtractsEditableTextFromScannedPdf() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS Vision framework."); + return; + } + + var engine = new MacOsVisionOcrEngine(); + OcrEngineRegistry.Register(engine); + + var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); + var workDir = Path.Combine(Path.GetTempPath(), $"filee-pdf-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + + try + { + var pdfPath = Path.Combine(workDir, "scanned.pdf"); + + // Create a pure scanned PDF with only a bitmap image using PdfSharp + using (var pdfDoc = new PdfSharp.Pdf.PdfDocument()) + { + var page = pdfDoc.AddPage(); + page.Width = PdfSharp.Drawing.XUnit.FromPoint(600); + page.Height = PdfSharp.Drawing.XUnit.FromPoint(400); + using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page); + using var img = PdfSharp.Drawing.XImage.FromFile(imagePath); + gfx.DrawImage(img, 0, 0, 600, 400); + pdfDoc.Save(pdfPath); + } + + Assert.True(File.Exists(pdfPath)); + + // Read the scanned PDF using PdfDocumentReader with OCR enabled + var mediaFolder = Path.Combine(workDir, "media"); + var document = PdfDocumentReader.Read(pdfPath, mediaFolder, engine); + + Assert.NotEmpty(document.Sections); + var paragraphs = document.Sections.SelectMany(s => s.Blocks).OfType().ToList(); + Assert.NotEmpty(paragraphs); + + var allText = string.Join(" ", paragraphs.SelectMany(p => p.Inlines).OfType().Select(t => t.Text)); + Assert.Contains("Filee", allText, StringComparison.OrdinalIgnoreCase); + + // Verify plain text reader also extracts the OCR text + var plainDoc = PdfDocumentReader.ReadText(pdfPath, engine); + var plainText = PdfDocumentReader.PlainText(plainDoc); + Assert.Contains("Filee", plainText, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + + [Fact] + public void ScannedPdfWithoutOcrStaysPictureOnly() + { + var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); + var workDir = Path.Combine(Path.GetTempPath(), $"filee-pdf-noocr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + + try + { + var pdfPath = Path.Combine(workDir, "scanned.pdf"); + using (var pdfDoc = new PdfSharp.Pdf.PdfDocument()) + { + var page = pdfDoc.AddPage(); + page.Width = PdfSharp.Drawing.XUnit.FromPoint(600); + page.Height = PdfSharp.Drawing.XUnit.FromPoint(400); + using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page); + using var img = PdfSharp.Drawing.XImage.FromFile(imagePath); + gfx.DrawImage(img, 0, 0, 600, 400); + pdfDoc.Save(pdfPath); + } + + var document = PdfDocumentReader.Read(pdfPath, Path.Combine(workDir, "media"), null, null, enableOcr: false); + var paragraphs = document.Sections.SelectMany(s => s.Blocks).OfType() + .SelectMany(p => p.Inlines).OfType().Select(t => t.Text).ToList(); + Assert.Empty(paragraphs); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + + private sealed class FixedPath(string path) : IOutputAllocator + { + public string? Allocate(string extension, string? suffix = null) => path; + } +} From 76d1d9d44229aa15e308812eff0ee8ed4b9096e6 Mon Sep 17 00:00:00 2001 From: smjrifle Date: Wed, 21 Oct 2026 23:17:02 +0545 Subject: [PATCH 2/8] docs(engines): clarify Windows and Linux Tesseract requirement --- docs/ENGINES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 38907d758..a2fe7a27e 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -35,7 +35,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, Live / Motion Photos → their video, a clip of a video → an iPhone Live Photo or Android Motion Photo (experimental), images → HEIC | | Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR; e-books → PDF (ranked above the HWPX writer, see below) | | Apple Vision | part of macOS (macOS only) | **scanned PDFs and images → editable text / DOCX / Markdown / HWPX** via native neural OCR (Latin, CJK, Cyrillic); zero download size | -| Tesseract | **optional, install yourself** (~30 MB + the trained data of each language) | OCR for scripts Apple Vision doesn't know: Devanagari (Nepali, Hindi, Sanskrit, Marathi), Cyrillic beyond Russian/Ukrainian, Thai, Greek, Arabic script and more; the only OCR on Windows and Linux (Linux has no built-in option, so this is required there) | +| Tesseract | **optional, install yourself** (~30 MB + the trained data of each language) | OCR for scripts Apple Vision doesn't know: Devanagari (Nepali, Hindi, Sanskrit, Marathi), Cyrillic beyond Russian/Ukrainian, Thai, Greek, Arabic script and more; currently the only OCR on Windows and Linux (Linux has no built-in option, and Windows built-in Media OCR lacks Devanagari and requires separate OS language packs, so Tesseract is required on both) | DOCX, XLSX, XLS, ODS and PPTX → PDF need neither Microsoft Office nor LibreOffice: they are read in-process, written as HWPX and rendered by rhwp. Anything the built-in readers understand (including PDF) is written as DOCX by the DOCX writer. From a223ff4eaaab614d0cbd44473a986a0a8309a165 Mon Sep 17 00:00:00 2001 From: smjrifle Date: Thu, 22 Oct 2026 21:09:41 +0545 Subject: [PATCH 3/8] fix(ocr): address review feedback on OCR engine integration - Remove direct MacOsVisionOcrEngine reference from CliHost (fixes CS0234 on Windows/Linux CI) - Guard invisible OCR layer extraction behind _enableOcr to preserve original scanned PDF rendering - Restore turned/rotated page rendering when OCR is disabled and prevent duplicate text overlay - Direct scan rendering to job WorkDirectory, propagate CancellationToken, and log OCR errors - Remove automated brew/winget execution in favor of guidance and "Use my copy..." flow - Default DocumentOptions.Ocr to false, enable automatic language detection on macOS 13+, and migrate settings to schema 33 - Support x86_64 struct return via objc_msgSend_stret for CGRect and release allocated Vision/NSImage objects - Remove leftover PaddleOCR references, update architecture/engines docs, and add Tesseract/tessdata to THIRD-PARTY-NOTICES - Fix format ID 'jpeg' -> 'jpg' in ImageOcrConverter, match localization strings in ko/ja/ru, and isolate test registry mocks Closes #288 --- THIRD-PARTY-NOTICES.md | 2 + docs/ARCHITECTURE.md | 18 +- docs/ENGINES.md | 5 +- src/Filee.App/Assets/i18n/ja.json | 4 +- src/Filee.App/Assets/i18n/ko.json | 4 +- src/Filee.App/Assets/i18n/ru.json | 4 +- .../Services/EngineDownloadService.cs | 252 ++---------------- .../ViewModels/PresetEditorViewModel.cs | 3 +- src/Filee.Cli/CliHost.cs | 2 - src/Filee.Core/Ocr/OcrEngineRegistry.cs | 9 + src/Filee.Core/Presets/Preset.cs | 2 +- src/Filee.Core/Settings/AppSettings.cs | 4 +- src/Filee.Core/Settings/UserDataStore.cs | 4 + src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs | 4 +- src/Filee.Engines/Ocr/ImageOcrConverter.cs | 2 +- src/Filee.Engines/Ocr/TesseractOcrEngine.cs | 9 +- src/Filee.Engines/Pdf/PdfDocumentReader.cs | 110 +++++--- src/Filee.Engines/Pdf/PdfTextConverter.cs | 13 +- .../MacOsVisionOcrEngine.cs | 40 ++- tests/Filee.App.Tests/EnginesPageTests.cs | 7 +- .../OcrEngineRegistryTests.cs | 13 +- tests/Filee.Core.Tests/UserDataStoreTests.cs | 28 +- .../Filee.Engines.Tests/TesseractOcrTests.cs | 5 +- tests/Filee.Platform.Tests/VisionOcrTests.cs | 11 +- 24 files changed, 232 insertions(+), 323 deletions(-) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 2e63361cb..a572f7606 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -78,6 +78,8 @@ Downloaded from their official release pages when the user chooses to install th | Microsoft Visual C++ Redistributable (for Ghostscript, conda-forge `vc14_runtime`) | Microsoft Visual C++ Redistributable license | https://github.com/conda-forge/vc-feedstock | | FFmpeg 9.0.2 (gyan.dev "full_build-shared" Windows build; ffmpeg.exe, ffprobe.exe and their libraries, incl. x264, x265, libvpx, LAME, Opus, Vorbis, Theora, OpenCORE AMR) | GPL-3.0-or-later (the build is configured with `--enable-gpl --enable-version3`); FFmpeg itself LGPL-2.1-or-later | https://ffmpeg.org, build: https://www.gyan.dev/ffmpeg/builds/ (source: https://github.com/GyanD/codexffmpeg) | | calibre (ebook-convert) | GPL-3.0 | https://calibre-ebook.com | +| Tesseract OCR | Apache-2.0 | https://github.com/tesseract-ocr/tesseract | +| Tesseract trained data (tessdata / tessdata_fast / tessdata_best) | Apache-2.0 | https://github.com/tesseract-ocr/tessdata_fast | These programs run as separate processes. Their source code is available from the linked projects. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d0d66ef0..4d953ec3b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,15 +16,17 @@ ┌──────────────────────────── Filee.Core ───────────────────────────────────────────┐ │ JobQueue ──► RoutePlanner (Dijkstra over converter edges) ──► IConverter steps │ │ FormatRegistry · Preset · OutputPathResolver · ToolbarProfile · ProfileSelector │ - └──────────────────────────────────────────────┬──────────────────────────────────────┘ + │ OcrEngineRegistry · IOcrEngine (Apple Vision on macOS, Tesseract elsewhere) │ + └──────────────────────────────────────────────┬──────────────────────────────────┘ ▼ ┌──────────────────────────── Filee.Engines ────────────────────────────────────────┐ │ Built in: Magick (images, RAW) · PdfSharp · Pdfium · PdfPig · Svg.Skia · ICNS │ │ HwpxWriter + DocxWriter over DocumentReaders (DOCX, XLSX, XLS, ODS, PPTX, │ │ PDF, HTML, EPUB, MOBI, FB2, HWPX, Markdown) · Spreadsheets · OOXML variants │ - │ · E-mail · Data · E-books · CAD · Fonts · Subtitles · 3D models │ + │ · E-mail · Data · E-books · CAD · Fonts · Subtitles · 3D models · Image OCR │ │ Bundled: rhwp (HWP→PDF, HWP↔HWPX) · 7-Zip (archives) · Unhwp · Markdig │ │ Optional: FFmpeg · LibreOffice + H2Orestart · calibre · Ghostscript · Pandoc │ + │ · Tesseract OCR │ └────────────────────────────────────────────────────────────────────────────────────┘ ``` @@ -52,6 +54,18 @@ "Compress into one archive" packs the dropped files, unconverted, with the archive engine (`IFileCombiner`). 6. **Report** – progress and results appear in the toast window and in Home → Recent conversions. +## Optical Character Recognition (OCR) + +Filee provides engine-neutral OCR abstractions in `Filee.Core.Ocr` (`IOcrEngine`, `OcrEngineRegistry`, `OcrPageResult`, `OcrLine`, `OcrWord`, `OcrRect`). + +- **Engine Discovery**: + - `OcrEngineRegistry` discovers available OCR engines without requiring callers to couple to platform-specific projects. + - On macOS, `MacOsVisionOcrEngine` (in `Filee.Platform.MacOS`) calls Apple's Vision framework (`VNRecognizeTextRequest`) via Objective-C runtime P/Invoke (`objc_msgSend` on arm64, `objc_msgSend_stret` for 32-byte `CGRect` on x86_64, with `setAutomaticallyDetectsLanguage:` on macOS 13+). It is discovered via reflection at runtime so Windows and Linux builds remain decoupled. + - Across all desktop platforms, `TesseractOcrEngine` (in `Filee.Engines.Ocr`) runs Tesseract CLI out-of-process with timeouts and argument lists, supporting 100+ languages including Devanagari (Nepali/Hindi), Indic, Cyrillic, Thai, and Arabic scripts. +- **Conversion Pipelines**: + - `ImageOcrConverter`: converts raster images directly to TXT, DOCX, and Markdown (`image-ocr` engine priority entry). + - `PdfDocumentReader`: when `DocumentOptions.Ocr` is enabled, runs OCR on scanned pages. Scanned PDFs with an existing invisible OCR layer (`TextRenderingMode.Neither`) use that layer directly when OCR is enabled, while preserving the scanned page picture when OCR is disabled. Rotated pages (`rotated && !_textOnly`) preserve their rendered scan picture. + ## Files dragged from a web browser A picture, a link to a file or a download dragged out of a browser opens the donut like a file from the file manager. diff --git a/docs/ENGINES.md b/docs/ENGINES.md index a2fe7a27e..31a08c840 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -74,8 +74,9 @@ the same on every PC. An external engine is looked for in this order: calibre and Ghostscript installers put it. Settings → *Engines* shows it as "Found on this PC", with a note when its version is older than the one Filee downloads. -Where Filee has no download of an engine (FFmpeg and Ghostscript on macOS, LibreOffice and Ghostscript on Linux), -step 3 is how it is found. Microsoft Office +Where Filee has no download of an engine (FFmpeg and Ghostscript on macOS, LibreOffice and Ghostscript on Linux, +Tesseract on all platforms), step 3 is how it is found; Settings → *Engines* shows install guidance (Homebrew, winget, +apt) and a "Use my copy…" button to point to its executable. Microsoft Office and Hancom Office are never used, even when installed. Running from source, the repository's `engines/` folder (filled by `build/fetch-engines.ps1`) is used. diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index bc5373247..5d6039dba 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -663,8 +663,8 @@ "engines.guidance.windows": "このPC向けの{0}はFileeからダウンロードできません。\n「手元のプログラムを使う…」を選び、インストール済みのものを指定してください。", "engines.package.tesseract.name": "Tesseract(多言語テキスト認識)", "engines.package.tesseract.description": "デーヴァナーガリー(ネパール語/ヒンディー語)、アラビア語、希少言語を含む100以上の言語のテキストを認識します。\nmacOSではApple Visionが英語、CJK、ヨーロッパ言語をネイティブにサポートしています。", - "engines.guidance.macos.tesseract": "HomebrewでTesseractをインストール(brew install tesseract tesseract-lang)してから「再確認」を選択してください。\nまたは「手持ちのコピーを使う…」を選択してbinフォルダーを指定してください。", - "engines.guidance.linux.tesseract": "パッケージマネージャーでTesseractをインストール(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)してから「再確認」を選択してください。\nまたは「手持ちのコピーを使う…」を選択してプログラムフォルダーを指定してください。", + "engines.guidance.macos.tesseract": "HomebrewでTesseractをインストール(brew install tesseract tesseract-lang)してから「再確認」を選択してください。\nまたは「手元のプログラムを使う…」を選択してbinフォルダーを指定してください。", + "engines.guidance.linux.tesseract": "パッケージマネージャーでTesseractをインストール(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)してから「再確認」を選択してください。\nまたは「手元のプログラムを使う…」を選択してプログラムフォルダーを指定してください。", "engines.guidance.windows.tesseract": "UB-Mannheim(github.com/UB-Mannheim/tesseract)からTesseractをインストールし、「再確認」を選択するかインストールフォルダーを指定してください。", "engines.use_own_app_folder": "{0}.appを含むフォルダーを選択してください", "engines.progress_detail": "{0}/{1} · {2}/秒 · {3}", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 379ada5ee..d8c991f22 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -680,8 +680,8 @@ "engines.guidance.windows": "이 PC용 {0} 다운로드는 없어요.\n“내 프로그램 쓰기…”로 설치된 프로그램을 선택해 주세요.", "engines.package.tesseract.name": "Tesseract (다국어 텍스트 인식)", "engines.package.tesseract.description": "데바나가리(네팔어/힌디어), 아랍어 및 희귀 문자를 포함한 100개 이상의 언어로 텍스트를 인식합니다.\nmacOS에서는 Apple Vision이 내장되어 영어, CJK 및 유럽어는 추가 설치 없이 동작합니다.", - "engines.guidance.macos.tesseract": "Homebrew로 Tesseract를 설치(brew install tesseract tesseract-lang)한 뒤 “다시 확인”을 선택하세요.\n또는 “내 사본 사용…”을 선택하고 bin 폴더를 지정하세요.", - "engines.guidance.linux.tesseract": "패키지 관리자로 Tesseract를 설치(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)한 뒤 “다시 확인”을 선택하세요.\n또는 “내 사본 사용…”을 선택하고 프로그램 폴더를 지정하세요.", + "engines.guidance.macos.tesseract": "Homebrew로 Tesseract를 설치(brew install tesseract tesseract-lang)한 뒤 “다시 확인”을 선택하세요.\n또는 “내 프로그램 쓰기…”을 선택하고 bin 폴더를 지정하세요.", + "engines.guidance.linux.tesseract": "패키지 관리자로 Tesseract를 설치(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)한 뒤 “다시 확인”을 선택하세요.\n또는 “내 프로그램 쓰기…”을 선택하고 프로그램 폴더를 지정하세요.", "engines.guidance.windows.tesseract": "UB-Mannheim(github.com/UB-Mannheim/tesseract)에서 Tesseract를 설치한 뒤 “다시 확인”을 선택하거나 설치 폴더를 지정하세요.", "engines.use_own_app_folder": "{0}.app이 들어 있는 폴더 선택", "engines.progress_detail": "{0} / {1} · {2}/s · {3}", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index a12e691ec..0a129bb32 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -680,8 +680,8 @@ "engines.guidance.windows": "Filee не может скачать {0} для этого компьютера.\nВыберите «Использовать свою копию…» и укажите установленную копию.", "engines.package.tesseract.name": "Tesseract (многоязычное распознавание текста)", "engines.package.tesseract.description": "Распознает текст на более чем 100 языках, включая деванагари (непальский/хинди), арабский и редкие письменности.\nВ macOS Apple Vision работает нативно для английского, CJK и европейских языков без него.", - "engines.guidance.macos.tesseract": "Установите Tesseract с помощью Homebrew (brew install tesseract tesseract-lang), затем выберите «Проверить снова».\nИли выберите «Использовать мою копию…» и укажите папку bin.", - "engines.guidance.linux.tesseract": "Установите Tesseract с помощью пакетного менеджера (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), затем выберите «Проверить снова».\nИли выберите «Использовать мою копию…» и укажите папку программы.", + "engines.guidance.macos.tesseract": "Установите Tesseract с помощью Homebrew (brew install tesseract tesseract-lang), затем выберите «Проверить снова».\nИли выберите «Использовать свою копию…» и укажите папку bin.", + "engines.guidance.linux.tesseract": "Установите Tesseract с помощью пакетного менеджера (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), затем выберите «Проверить снова».\nИли выберите «Использовать свою копию…» и укажите папку программы.", "engines.guidance.windows.tesseract": "Установите Tesseract из UB-Mannheim (github.com/UB-Mannheim/tesseract), затем выберите «Проверить снова» или укажите папку установки.", "engines.use_own_app_folder": "Выберите папку, в которой лежит {0}.app", "engines.progress_detail": "{0} из {1} · {2}/с · {3}", diff --git a/src/Filee.App/Services/EngineDownloadService.cs b/src/Filee.App/Services/EngineDownloadService.cs index 14cd8cace..59ab2239d 100644 --- a/src/Filee.App/Services/EngineDownloadService.cs +++ b/src/Filee.App/Services/EngineDownloadService.cs @@ -44,9 +44,7 @@ public EnginePackageState(EnginePackage package, ILocalizer loc) public string Sizes => EngineDownloads.CanDownload(Package) ? _loc.Format("engines.package.size", FormatBytes(EngineDownloads.DownloadSize(Package)), FormatBytes(Package.InstalledSize)) - : CanSystemInstall(Package) - ? _loc.Format("engines.package.size", FormatBytes(350_000_000), FormatBytes(Package.InstalledSize)) - : _loc["engines.package.system_required"]; + : _loc["engines.package.system_required"]; /// /// How to get an engine Filee doesn't download on this system (FFmpeg and Ghostscript from Homebrew on macOS, @@ -56,7 +54,7 @@ public string? InstallGuidance { get { - if (EngineDownloads.CanDownload(Package) || CanSystemInstall(Package)) + if (EngineDownloads.CanDownload(Package)) return null; var system = OperatingSystem.IsMacOS() ? "macos" : OperatingSystem.IsLinux() ? "linux" : "windows"; var key = $"engines.guidance.{system}.{Package.Id}"; @@ -116,7 +114,7 @@ public string? InstallGuidance /// The engine is there: Filee's download or the user's own copy. public bool IsInstalled => Status is EnginePackageStatus.Installed or EnginePackageStatus.OwnCopy; - public bool CanRemove => Status == EnginePackageStatus.Installed || (Package.Id == "tesseract" && Status == EnginePackageStatus.OwnCopy && FromSystem); + public bool CanRemove => Status == EnginePackageStatus.Installed; /// The user chose this copy ("Stop using it" applies). public bool UsesOwnCopy => Status == EnginePackageStatus.OwnCopy && !FromSystem; public bool ShowsCopyFolder => Status == EnginePackageStatus.OwnCopy; @@ -124,33 +122,7 @@ public string? InstallGuidance || (Status == EnginePackageStatus.OwnCopy && FromSystem); public bool IsBusy => Status is EnginePackageStatus.Queued or EnginePackageStatus.Downloading or EnginePackageStatus.Unpacking; /// Also when a copy on the PC is used: Filee's download is the tested version and wins once installed. - public bool CanInstall => (EngineDownloads.CanDownload(Package) && (Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed || (Status == EnginePackageStatus.OwnCopy && FromSystem))) - || (CanSystemInstall(Package) && Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed); - - /// - /// Whether the app can drive an in-place install of on this machine: Homebrew on - /// macOS, winget on Windows. Linux has no handler here (package managers vary too much to drive one - /// generically), so it keeps the same guidance-text + "Use my copy…" flow as every other engine. - /// - internal static bool CanSystemInstall(EnginePackage package) => - package.Id == "tesseract" && (OperatingSystem.IsMacOS() ? LocateBrew() is not null : OperatingSystem.IsWindows()); - - /// Homebrew's own binary, at its two standard prefixes or on PATH; null when it isn't installed. - internal static string? LocateBrew() - { - if (File.Exists("/opt/homebrew/bin/brew")) return "/opt/homebrew/bin/brew"; - if (File.Exists("/usr/local/bin/brew")) return "/usr/local/bin/brew"; - var path = Environment.GetEnvironmentVariable("PATH"); - if (path is not null) - { - foreach (var part in path.Split(':')) - { - var candidate = Path.Combine(part, "brew"); - if (File.Exists(candidate)) return candidate; - } - } - return null; - } + public bool CanInstall => EngineDownloads.CanDownload(Package) && (Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed || (Status == EnginePackageStatus.OwnCopy && FromSystem)); public string StatusText => Status switch { @@ -285,33 +257,26 @@ public async Task InstallAsync(EnginePackageState state) await _oneAtATime.WaitAsync(cancellation.Token); try { - if (state.Package.Id == "tesseract" && !EngineDownloads.CanDownload(state.Package)) - { - await InstallTesseractSystemAsync(state, cancellation.Token); - } - else + // Progress reports on the UI thread it was created on. + var clock = Stopwatch.StartNew(); + var rate = new TransferRate(); + var progress = new Progress(p => { - // Progress reports on the UI thread it was created on. - var clock = Stopwatch.StartNew(); - var rate = new TransferRate(); - var progress = new Progress(p => + if (!state.IsBusy) + return; + state.Status = p.Stage == EngineInstallStage.Downloading ? EnginePackageStatus.Downloading : EnginePackageStatus.Unpacking; + state.Progress = p.Fraction * 100; + if (p.Stage == EngineInstallStage.Downloading && p.BytesTotal > 0) { - if (!state.IsBusy) - return; - state.Status = p.Stage == EngineInstallStage.Downloading ? EnginePackageStatus.Downloading : EnginePackageStatus.Unpacking; - state.Progress = p.Fraction * 100; - if (p.Stage == EngineInstallStage.Downloading && p.BytesTotal > 0) - { - rate.Add(clock.Elapsed, p.BytesDone); - state.ShowTransfer(p.BytesDone, p.BytesTotal, rate); - } - else - { - state.ProgressDetail = ""; - } - }); - await Task.Run(() => _installer.InstallAsync(state.Package, progress, cancellation.Token), cancellation.Token); - } + rate.Add(clock.Elapsed, p.BytesDone); + state.ShowTransfer(p.BytesDone, p.BytesTotal, rate); + } + else + { + state.ProgressDetail = ""; + } + }); + await Task.Run(() => _installer.InstallAsync(state.Package, progress, cancellation.Token), cancellation.Token); } finally { @@ -340,142 +305,6 @@ public async Task InstallAsync(EnginePackageState state) } } - private static async Task InstallTesseractSystemAsync(EnginePackageState state, CancellationToken cancellationToken) - { - try - { - if (OperatingSystem.IsMacOS()) - { - var brew = EnginePackageState.LocateBrew(); - if (brew is null) - throw new InvalidOperationException("Homebrew was not found. Install Homebrew from https://brew.sh first."); - - state.Status = EnginePackageStatus.Downloading; - state.Progress = 5; - state.ProgressDetail = "Starting Homebrew install..."; - - var psi = new ProcessStartInfo - { - FileName = brew, - ArgumentList = { "install", "tesseract", "tesseract-lang" }, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - var brewDir = Path.GetDirectoryName(brew); - var currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - if (brewDir is not null && !currentPath.Contains(brewDir)) - psi.Environment["PATH"] = brewDir + ":" + currentPath; - psi.Environment["HOMEBREW_NO_AUTO_UPDATE"] = "1"; - psi.Environment["HOMEBREW_NO_ENV_HINTS"] = "1"; - - using var process = new Process { StartInfo = psi }; - process.OutputDataReceived += (_, e) => - { - if (!string.IsNullOrWhiteSpace(e.Data)) - { - Dispatcher.UIThread.Post(() => - { - var line = e.Data.Trim(); - state.ProgressDetail = line; - if (line.StartsWith("==> Downloading", StringComparison.OrdinalIgnoreCase)) - { - state.Status = EnginePackageStatus.Downloading; - if (state.Progress < 50) state.Progress += 10; - } - else if (line.StartsWith("==> Pouring", StringComparison.OrdinalIgnoreCase) || - line.StartsWith("==> Installing", StringComparison.OrdinalIgnoreCase)) - { - state.Status = EnginePackageStatus.Unpacking; - if (state.Progress < 90) state.Progress += 10; - } - }); - } - }; - process.ErrorDataReceived += (_, e) => - { - if (!string.IsNullOrWhiteSpace(e.Data)) - { - Dispatcher.UIThread.Post(() => - { - state.ProgressDetail = e.Data.Trim(); - }); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using (cancellationToken.Register(() => - { - try { process.Kill(true); } catch { } - })) - { - await process.WaitForExitAsync(cancellationToken); - } - - if (process.ExitCode != 0) - throw new InvalidOperationException($"Homebrew install failed with exit code {process.ExitCode}."); - - state.Progress = 100; - state.ProgressDetail = ""; - } - else if (OperatingSystem.IsWindows()) - { - state.Status = EnginePackageStatus.Downloading; - state.Progress = 10; - state.ProgressDetail = "Installing Tesseract with winget..."; - - var psi = new ProcessStartInfo - { - FileName = "winget", - ArgumentList = { "install", "UB-Mannheim.TesseractOCR", "--accept-source-agreements", "--accept-package-agreements" }, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = new Process { StartInfo = psi }; - process.OutputDataReceived += (_, e) => - { - if (!string.IsNullOrWhiteSpace(e.Data)) - { - Dispatcher.UIThread.Post(() => - { - state.ProgressDetail = e.Data.Trim(); - }); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - - using (cancellationToken.Register(() => - { - try { process.Kill(true); } catch { } - })) - { - await process.WaitForExitAsync(cancellationToken); - } - - if (process.ExitCode != 0) - throw new InvalidOperationException($"winget install failed with exit code {process.ExitCode}."); - } - else - { - throw new PlatformNotSupportedException("Automatic installation is not supported on this platform. Please install tesseract using your package manager."); - } - } - finally - { - state.Cancellation = null; - state.ProgressDetail = ""; - } - } - public void Cancel(EnginePackageState state) => state.Cancellation?.Cancel(); /// @@ -517,43 +346,6 @@ public bool Uninstall(EnginePackageState state) return false; try { - if (state.Package.Id == "tesseract") - { - if (OperatingSystem.IsMacOS()) - { - var brew = EnginePackageState.LocateBrew(); - if (brew is not null) - { - var psi = new ProcessStartInfo - { - FileName = brew, - ArgumentList = { "uninstall", "tesseract", "tesseract-lang" }, - UseShellExecute = false, - CreateNoWindow = true - }; - var brewDir = Path.GetDirectoryName(brew); - var currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; - if (brewDir is not null && !currentPath.Contains(brewDir)) - psi.Environment["PATH"] = brewDir + ":" + currentPath; - psi.Environment["HOMEBREW_NO_AUTO_UPDATE"] = "1"; - psi.Environment["HOMEBREW_NO_ENV_HINTS"] = "1"; - using var process = Process.Start(psi); - process?.WaitForExit(); - } - } - else if (OperatingSystem.IsWindows()) - { - using var process = Process.Start(new ProcessStartInfo - { - FileName = "winget", - ArgumentList = { "uninstall", "UB-Mannheim.TesseractOCR" }, - UseShellExecute = false, - CreateNoWindow = true - }); - process?.WaitForExit(); - } - EngineEnvironment.ForgetSystemCopies(); - } _installer.Uninstall(state.Package); state.Status = StatusOf(state); _catalog.Refresh(); diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 43bc2b5f1..ac723bc5f 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -181,8 +181,7 @@ .. FormatRegistry.Known new Choice("tg", "Tajik / Тоҷикӣ"), new Choice("tt", "Tatar / Татарча"), ]; - // Vision and Tesseract each claim a real, limited set of languages (SupportedLanguages); the old PaddleOCR - // engine claimed over 100 it could never actually recognize (no bundled model, no install path). Only offer + // Vision and Tesseract each claim a real, supported set of languages (SupportedLanguages). Only offer // what a registered engine can really attempt, so picking a language never leads straight to "engine missing". OcrLanguages = [.. OcrLanguages.Where(c => c.Value is null || OcrEngineRegistry.AnyEngineSupports(c.Value))]; diff --git a/src/Filee.Cli/CliHost.cs b/src/Filee.Cli/CliHost.cs index dae34da3e..14bb8b43e 100644 --- a/src/Filee.Cli/CliHost.cs +++ b/src/Filee.Cli/CliHost.cs @@ -34,8 +34,6 @@ public static CliHost Create(string? dataDirectory = null) EngineEnvironment.LibreOfficeForDocuments = store.Settings.LibreOfficeForDocuments; EngineEnvironment.LibreOfficeForSheets = store.Settings.LibreOfficeForSheets; var converters = EngineRegistry.CreateAll(new EngineEnvironment(store.Directory)); - if (OperatingSystem.IsMacOS()) - Filee.Core.Ocr.OcrEngineRegistry.Register(new Filee.Platform.MacOS.MacOsVisionOcrEngine()); var catalog = new ConverterCatalog(converters) { Priority = store.Settings.EnginePriority }; var queue = new JobQueue(catalog, EngineRegistry.FindPdfMerger(converters), combiner: EngineRegistry.FindFileCombiner(converters), tiffMerger: EngineRegistry.FindTiffMerger(converters)); diff --git a/src/Filee.Core/Ocr/OcrEngineRegistry.cs b/src/Filee.Core/Ocr/OcrEngineRegistry.cs index 504bdf7de..998a968b8 100644 --- a/src/Filee.Core/Ocr/OcrEngineRegistry.cs +++ b/src/Filee.Core/Ocr/OcrEngineRegistry.cs @@ -132,4 +132,13 @@ private static void DiscoverNativeEngine() return RegisteredEngines.FirstOrDefault(e => e.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); } } + + /// Clears registered engines for test isolation. + internal static void ResetForTesting() + { + lock (Sync) + { + RegisteredEngines.Clear(); + } + } } diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index eada26a57..96957f173 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -426,7 +426,7 @@ public sealed class DocumentOptions /// /// Optical Character Recognition for scanned pages and images. /// - public bool Ocr { get; set; } = true; + public bool Ocr { get; set; } /// /// Optical Character Recognition language code (e.g. "en-US", "ko-KR"). Null uses the system default. diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 4f488dcba..9304306cf 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 32; + public const int CurrentSchemaVersion = 33; public int SchemaVersion { get; set; } = CurrentSchemaVersion; @@ -184,7 +184,7 @@ public sealed class AppSettings [ "magick", "pdfium", "pdfsharp", "rhwp", "unhwp", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", - "subtitle", "wic", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice", + "subtitle", "wic", "image-ocr", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice", ]; /// Set once the first-run welcome has been shown. diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 7aff0c842..5e2910dae 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -403,6 +403,10 @@ public static void Apply(AppSettings settings) if (settings.SchemaVersion < 21) InsertBefore(settings.EnginePriority, "wic", settings.EnginePriority.Contains("ffmpeg") ? "ffmpeg" : "pandoc"); + // v33: built-in "image-ocr" engine (image to text/docx/md via Vision/Tesseract) before external converters. + if (settings.SchemaVersion < 33 && !settings.EnginePriority.Contains("image-ocr")) + InsertBefore(settings.EnginePriority, "image-ocr", settings.EnginePriority.Contains("ffmpeg") ? "ffmpeg" : "pandoc"); + if (settings.Triggers.Count == 0) settings.Triggers = TriggerGesture.Defaults(); diff --git a/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs b/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs index 2920b6556..725c93d1f 100644 --- a/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs +++ b/src/Filee.Engines/Hwp/Hwpx/DocumentReaders.cs @@ -111,9 +111,9 @@ public static async Task ReadAsync(string path, string format, string var media = Path.Combine(workDirectory, $"media-{Guid.NewGuid():N}"); if (format.Equals("pdf", StringComparison.OrdinalIgnoreCase)) { - var enableOcr = preset?.Document.Ocr ?? true; + var enableOcr = preset?.Document.Ocr ?? false; var ocrLang = preset?.Document.OcrLanguage; - return await Task.Run(() => PdfDocumentReader.Read(path, media, null, ocrLang, enableOcr), cancellationToken); + return await PdfDocumentReader.ReadAsync(path, media, null, ocrLang, enableOcr, workDirectory, cancellationToken: cancellationToken); } if (BuiltIn.TryGetValue(format, out var reader)) return await Task.Run(() => reader(path, media), cancellationToken); diff --git a/src/Filee.Engines/Ocr/ImageOcrConverter.cs b/src/Filee.Engines/Ocr/ImageOcrConverter.cs index 64e2d217a..259c10497 100644 --- a/src/Filee.Engines/Ocr/ImageOcrConverter.cs +++ b/src/Filee.Engines/Ocr/ImageOcrConverter.cs @@ -15,7 +15,7 @@ namespace Filee.Engines.Ocr; /// public sealed class ImageOcrConverter : IConverter { - private static readonly string[] ImageFormats = ["png", "jpg", "jpeg", "webp", "tiff", "bmp"]; + private static readonly string[] ImageFormats = ["png", "jpg", "webp", "tiff", "bmp"]; public string Id => "image-ocr"; public string DisplayName => "Image OCR (built-in)"; diff --git a/src/Filee.Engines/Ocr/TesseractOcrEngine.cs b/src/Filee.Engines/Ocr/TesseractOcrEngine.cs index 2baea8ce3..c8eed04a5 100644 --- a/src/Filee.Engines/Ocr/TesseractOcrEngine.cs +++ b/src/Filee.Engines/Ocr/TesseractOcrEngine.cs @@ -1,6 +1,6 @@ -// Tesseract OCR engine: a single self-contained binary plus pinned .traineddata files, found the same way as every -// other optional engine (OwnCopies, Filee's own copy, then one already on the PC). The real cross-platform, no-Python -// fallback for platforms without a native OCR API (Windows, Linux) and for scripts Apple Vision doesn't recognize. +// Tesseract OCR engine, run as a subprocess with ArgumentList and a timeout, found via the user's own copy or on +// the system path. Cross-platform fallback for platforms without a native OCR API (Windows, Linux) and for scripts +// Apple Vision does not recognize (Devanagari, Cyrillic beyond Russian/Ukrainian, Thai, Greek, Arabic script, ...). using System.Globalization; using Filee.Core.Ocr; @@ -25,8 +25,7 @@ public sealed class TesseractOcrEngine : IOcrEngine /// /// Filee's language codes this engine can be asked to recognize, limited to what Tesseract's own trained-data /// set actually ships (tessdata_fast/best): asking for a code outside this list always fails, so the UI and - /// only offer what can really work, instead of PaddleOCR's old claimed-but-never - /// bundled 100+ languages. + /// only offer what can really work. /// public IReadOnlyList SupportedLanguages => [.. LanguageMap.Keys]; diff --git a/src/Filee.Engines/Pdf/PdfDocumentReader.cs b/src/Filee.Engines/Pdf/PdfDocumentReader.cs index bd67cde4a..073377417 100644 --- a/src/Filee.Engines/Pdf/PdfDocumentReader.cs +++ b/src/Filee.Engines/Pdf/PdfDocumentReader.cs @@ -20,6 +20,8 @@ using Filee.Core.Ocr; using Filee.Engines.Hwp.Hwpx; using Filee.Engines.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using PDFtoImage; using SkiaSharp; using UglyToad.PdfPig; @@ -106,10 +108,12 @@ internal sealed partial class PdfDocumentReader private readonly IOcrEngine? _ocrEngine; private readonly string? _ocrLanguage; private readonly bool _enableOcr; + private readonly string? _workDirectory; + private readonly ILogger _logger; private byte[]? _pdfBytes; private int _pictures; - private PdfDocumentReader(string path, string mediaFolder, bool textOnly, IOcrEngine? ocrEngine = null, string? ocrLanguage = null, bool enableOcr = true) + private PdfDocumentReader(string path, string mediaFolder, bool textOnly, IOcrEngine? ocrEngine = null, string? ocrLanguage = null, bool enableOcr = false, string? workDirectory = null, ILogger? logger = null) { _path = path; _mediaFolder = mediaFolder; @@ -117,14 +121,19 @@ private PdfDocumentReader(string path, string mediaFolder, bool textOnly, IOcrEn _ocrEngine = ocrEngine; _ocrLanguage = ocrLanguage; _enableOcr = enableOcr; + _workDirectory = workDirectory; + _logger = logger ?? NullLogger.Instance; } /// Reads with pictures (extracted into ) and scans. public static HDocument Read(string path, string mediaFolder) => new PdfDocumentReader(path, mediaFolder, textOnly: false).ReadDocument(); - public static HDocument Read(string path, string mediaFolder, IOcrEngine? ocrEngine, string? ocrLanguage = null, bool enableOcr = true) => - new PdfDocumentReader(path, mediaFolder, textOnly: false, ocrEngine, ocrLanguage, enableOcr).ReadDocument(); + public static HDocument Read(string path, string mediaFolder, IOcrEngine? ocrEngine, string? ocrLanguage = null, bool enableOcr = false, string? workDirectory = null, ILogger? logger = null) => + new PdfDocumentReader(path, mediaFolder, textOnly: false, ocrEngine, ocrLanguage, enableOcr, workDirectory, logger).ReadDocument(); + + public static Task ReadAsync(string path, string mediaFolder, IOcrEngine? ocrEngine, string? ocrLanguage = null, bool enableOcr = false, string? workDirectory = null, ILogger? logger = null, CancellationToken cancellationToken = default) => + new PdfDocumentReader(path, mediaFolder, textOnly: false, ocrEngine, ocrLanguage, enableOcr, workDirectory, logger).ReadDocumentAsync(cancellationToken); /// /// Reads only the text, including invisible text (the OCR layer of scanned PDFs); pictures and scans are left out. @@ -132,8 +141,11 @@ public static HDocument Read(string path, string mediaFolder, IOcrEngine? ocrEng public static HDocument ReadText(string path) => new PdfDocumentReader(path, "", textOnly: true).ReadDocument(); - public static HDocument ReadText(string path, IOcrEngine? ocrEngine, string? ocrLanguage = null, bool enableOcr = true) => - new PdfDocumentReader(path, "", textOnly: true, ocrEngine, ocrLanguage, enableOcr).ReadDocument(); + public static HDocument ReadText(string path, IOcrEngine? ocrEngine, string? ocrLanguage = null, bool enableOcr = false, string? workDirectory = null, ILogger? logger = null) => + new PdfDocumentReader(path, "", textOnly: true, ocrEngine, ocrLanguage, enableOcr, workDirectory, logger).ReadDocument(); + + public static Task ReadTextAsync(string path, IOcrEngine? ocrEngine, string? ocrLanguage = null, bool enableOcr = false, string? workDirectory = null, ILogger? logger = null, CancellationToken cancellationToken = default) => + new PdfDocumentReader(path, "", textOnly: true, ocrEngine, ocrLanguage, enableOcr, workDirectory, logger).ReadDocumentAsync(cancellationToken); // ───────────────────────── Model of a page ───────────────────────── @@ -215,7 +227,9 @@ private sealed class PageContent(int index, double width, double height) // ───────────────────────── Document ───────────────────────── - private HDocument ReadDocument() + private HDocument ReadDocument() => ReadDocumentAsync(CancellationToken.None).GetAwaiter().GetResult(); + + private async Task ReadDocumentAsync(CancellationToken cancellationToken) { PdfDocument pdf; try @@ -233,7 +247,10 @@ private HDocument ReadDocument() try { foreach (var page in pdf.GetPages()) - pages.Add(ReadPage(page)); + { + cancellationToken.ThrowIfCancellationRequested(); + pages.Add(await ReadPageAsync(page, cancellationToken)); + } } catch (PdfDocumentEncryptedException) { @@ -283,13 +300,23 @@ public static IReadOnlyList PageTexts(string path, IReadOnlyList pa } } - private PageContent ReadPage(Page page) + private PageContent ReadPage(Page page) => ReadPageAsync(page, CancellationToken.None).GetAwaiter().GetResult(); + + private async Task ReadPageAsync(Page page, CancellationToken cancellationToken) { var crop = page.CropBox.Bounds; // A page turned by 90° is shown (and rendered) with width and height swapped. var rotated = page.Rotation.Value % 180 != 0; var content = new PageContent(page.Number - 1, rotated ? crop.Height : crop.Width, rotated ? crop.Width : crop.Height); + // Turned pages when not text-only: always keep the rendered page, exactly as in main. + if (rotated && !_textOnly) + { + content.Scanned = true; + content.ScanPath = RenderScan(page.Number - 1); + return content; + } + var links = page.GetHyperlinks() .Where(h => h.Uri is { } uri && (uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || uri.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))) .Select(h => (Bounds: Shift(h.Bounds, crop), Uri: h.Uri!)) @@ -325,43 +352,41 @@ private PageContent ReadPage(Page page) if (words.Count == 0) { - // First check if the page already had an invisible OCR layer that was skipped for formatting - foreach (var word in pageWords) + // When OCR is enabled, first check if the page already has an invisible OCR text layer (TextRenderingMode.Neither) + if (_enableOcr) { - if (string.IsNullOrWhiteSpace(word.Text)) - continue; - var letters = word.Letters.Where(l => !string.IsNullOrWhiteSpace(l.Value)).ToList(); - if (letters.Count == 0) - continue; - var lead = string.IsNullOrWhiteSpace(word.Lead.Value) ? letters[0] : word.Lead; - var box = Shift(word.Box, crop); - var size = Median(letters.Select(LetterSize)); - var center = new PdfPoint((box.Left + box.Right) / 2, (box.Top + box.Bottom) / 2); - var link = links.FirstOrDefault(l => Inside(l.Bounds, center)).Uri; - words.Add(new TextWord(CleanText(word.Text), box.Left, box.Right, box.Top, box.Bottom, - lead.StartBaseLine.Y - crop.Bottom, Math.Max(1, size), Format(lead, size), link)); + foreach (var word in pageWords) + { + if (string.IsNullOrWhiteSpace(word.Text)) + continue; + var letters = word.Letters.Where(l => !string.IsNullOrWhiteSpace(l.Value)).ToList(); + if (letters.Count == 0) + continue; + var lead = string.IsNullOrWhiteSpace(word.Lead.Value) ? letters[0] : word.Lead; + var box = Shift(word.Box, crop); + var size = Median(letters.Select(LetterSize)); + var center = new PdfPoint((box.Left + box.Right) / 2, (box.Top + box.Bottom) / 2); + var link = links.FirstOrDefault(l => Inside(l.Bounds, center)).Uri; + words.Add(new TextWord(CleanText(word.Text), box.Left, box.Right, box.Top, box.Bottom, + lead.StartBaseLine.Y - crop.Bottom, Math.Max(1, size), Format(lead, size), link)); + } } - } - if (words.Count == 0 || (rotated && !_textOnly)) - { - if (_enableOcr) + // If still no words and OCR is enabled, run the OCR engine on the rendered scan + if (words.Count == 0 && _enableOcr) { var ocr = _ocrEngine ?? OcrEngineRegistry.GetDefault(_ocrLanguage); if (ocr is not null && ocr.IsAvailable) { var scanPath = RenderScan(page.Number - 1); + var isTempScan = _textOnly || string.IsNullOrEmpty(_mediaFolder); try { - var ocrResult = ocr.RecognizeAsync(scanPath, OcrEngineRegistry.ResolveLanguage(ocr, _ocrLanguage)).GetAwaiter().GetResult(); + var ocrResult = await ocr.RecognizeAsync(scanPath, OcrEngineRegistry.ResolveLanguage(ocr, _ocrLanguage), cancellationToken); if (ocrResult.Words.Count > 0) { var scaleX = content.Width / (double)ocrResult.ImageWidth; var scaleY = content.Height / (double)ocrResult.ImageHeight; - // One baseline/top/bottom per OCR-detected line, not per word: on a skewed or photographed - // scan each word's own pixel box drifts slightly in Y, which (once scaled to PDF points) - // is enough for Lines() below to split a single real text line into several interleaved - // reconstructed lines. Tesseract already groups words into lines; use that box. foreach (var line in ocrResult.Lines) { var pdfTop = content.Height - (line.Box.Top * scaleY); @@ -377,9 +402,20 @@ private PageContent ReadPage(Page page) } } } - catch + catch (OperationCanceledException) { - // Fall back to pure scan if OCR encounters an error + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OCR failed on PDF page {Page}", page.Number); + } + finally + { + if (isTempScan && words.Count > 0 && File.Exists(scanPath)) + { + try { File.Delete(scanPath); } catch { } + } } if (words.Count == 0) @@ -394,7 +430,7 @@ private PageContent ReadPage(Page page) if (words.Count == 0) { - // A scan, a page of drawings, or a turned page: the rendered page keeps everything. + // A scan, a page of drawings, or a page without OCR: the rendered page keeps everything. content.Scanned = true; if (!_textOnly) content.ScanPath = RenderScan(page.Number - 1); @@ -569,7 +605,11 @@ private static bool IsPlainJpeg(IPdfImage image) private string NewMediaPath(string extension) { - var folder = string.IsNullOrEmpty(_mediaFolder) ? Path.Combine(Path.GetTempPath(), "filee-media") : _mediaFolder; + var folder = !string.IsNullOrEmpty(_mediaFolder) + ? _mediaFolder + : !string.IsNullOrEmpty(_workDirectory) + ? Path.Combine(_workDirectory, "pdf-scans") + : Path.Combine(Path.GetTempPath(), $"filee-pdf-{Guid.NewGuid():N}"); Directory.CreateDirectory(folder); return Path.Combine(folder, $"pdf-{++_pictures}.{extension}"); } diff --git a/src/Filee.Engines/Pdf/PdfTextConverter.cs b/src/Filee.Engines/Pdf/PdfTextConverter.cs index 1588eed22..9be3f9076 100644 --- a/src/Filee.Engines/Pdf/PdfTextConverter.cs +++ b/src/Filee.Engines/Pdf/PdfTextConverter.cs @@ -35,12 +35,13 @@ public sealed class PdfTextConverter : IConverter public EngineStatus GetStatus() => EngineStatus.Available("PDF → TXT, Markdown", $"{EngineVersions.BuiltIn} · {EngineVersions.Library("PdfPig", typeof(UglyToad.PdfPig.PdfDocument))}"); public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => - Task.Run(() => step.To == "md" ? Markdown(step, progress, cancellationToken) : Text(step, progress, cancellationToken), cancellationToken); + step.To == "md" ? MarkdownAsync(step, progress, cancellationToken) : TextAsync(step, progress, cancellationToken); - private static IReadOnlyList Text(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + private static async Task> TextAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { progress?.Report(0.1); - var text = PdfDocumentReader.PlainText(PdfDocumentReader.ReadText(step.InputPath, null, step.Preset.Document.OcrLanguage, step.Preset.Document.Ocr)); + var doc = await PdfDocumentReader.ReadTextAsync(step.InputPath, null, step.Preset.Document.OcrLanguage, step.Preset.Document.Ocr, step.WorkDirectory, cancellationToken: cancellationToken); + var text = PdfDocumentReader.PlainText(doc); cancellationToken.ThrowIfCancellationRequested(); if (text.Length == 0) throw NoText(); @@ -54,13 +55,13 @@ private static IReadOnlyList Text(ConversionStep step, IProgress return [output]; } - private static IReadOnlyList Markdown(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + private static async Task> MarkdownAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { progress?.Report(0.1); var textOnly = step.Preset.Document.LeaveOutPictures; var document = textOnly - ? PdfDocumentReader.ReadText(step.InputPath, null, step.Preset.Document.OcrLanguage, step.Preset.Document.Ocr) - : PdfDocumentReader.Read(step.InputPath, Path.Combine(step.WorkDirectory, $"media-{Guid.NewGuid():N}"), null, step.Preset.Document.OcrLanguage, step.Preset.Document.Ocr); + ? await PdfDocumentReader.ReadTextAsync(step.InputPath, null, step.Preset.Document.OcrLanguage, step.Preset.Document.Ocr, step.WorkDirectory, cancellationToken: cancellationToken) + : await PdfDocumentReader.ReadAsync(step.InputPath, Path.Combine(step.WorkDirectory, $"media-{Guid.NewGuid():N}"), null, step.Preset.Document.OcrLanguage, step.Preset.Document.Ocr, step.WorkDirectory, cancellationToken: cancellationToken); cancellationToken.ThrowIfCancellationRequested(); if (textOnly && PdfDocumentReader.PlainText(document).Length == 0) throw NoText(); diff --git a/src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs b/src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs index 780f9e863..61fe78099 100644 --- a/src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs +++ b/src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs @@ -57,13 +57,15 @@ public Task RecognizeAsync(string imagePath, string? language = n if (nsPath == 0) return OcrPageResult.Empty(width, height); + nint request = 0; + nint handler = 0; try { var urlClass = objc_getClass("NSURL"); var fileUrl = Send(urlClass, sel_registerName("fileURLWithPath:"), nsPath); var reqClass = objc_getClass("VNRecognizeTextRequest"); - var request = Send(Send(reqClass, sel_registerName("alloc")), sel_registerName("init")); + request = Send(Send(reqClass, sel_registerName("alloc")), sel_registerName("init")); SendInteger(request, sel_registerName("setRecognitionLevel:"), 0); // 0 = VNRequestTextRecognitionLevelAccurate SendBool(request, sel_registerName("setUsesLanguageCorrection:"), true); @@ -78,11 +80,15 @@ public Task RecognizeAsync(string imagePath, string? language = n CFRelease(langString); } } + else if (OperatingSystem.IsMacOSVersionAtLeast(13)) + { + SendBool(request, sel_registerName("setAutomaticallyDetectsLanguage:"), true); + } var dictClass = objc_getClass("NSDictionary"); var emptyDict = Send(dictClass, sel_registerName("dictionary")); var handlerClass = objc_getClass("VNImageRequestHandler"); - var handler = Send(Send(handlerClass, sel_registerName("alloc")), sel_registerName("initWithURL:options:"), fileUrl, emptyDict); + handler = Send(Send(handlerClass, sel_registerName("alloc")), sel_registerName("initWithURL:options:"), fileUrl, emptyDict); var arrayClass2 = objc_getClass("NSArray"); var requestsArray = Send(arrayClass2, sel_registerName("arrayWithObject:"), request); @@ -161,6 +167,10 @@ public Task RecognizeAsync(string imagePath, string? language = n } finally { + if (handler != 0) + SendVoid(handler, sel_registerName("release")); + if (request != 0) + SendVoid(request, sel_registerName("release")); CFRelease(nsPath); } }, cancellationToken); @@ -180,8 +190,15 @@ private static (int Width, int Height) GetImageDimensions(string path) var image = Send(alloc, sel_registerName("initWithContentsOfFile:"), nsPath); if (image == 0) return (0, 0); - var size = SendSize(image, sel_registerName("size")); - return ((int)Math.Round(size.Width), (int)Math.Round(size.Height)); + try + { + var size = SendSize(image, sel_registerName("size")); + return ((int)Math.Round(size.Width), (int)Math.Round(size.Height)); + } + finally + { + SendVoid(image, sel_registerName("release")); + } } finally { @@ -262,7 +279,20 @@ private struct CGSize private static extern float SendFloat(nint receiver, nint selector); [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern CGRect SendRect(nint receiver, nint selector); + private static extern CGRect SendRectArm64(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend_stret")] + private static extern void SendRectStret(out CGRect result, nint receiver, nint selector); + + private static CGRect SendRect(nint receiver, nint selector) + { + if (RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + SendRectStret(out var rect, receiver, selector); + return rect; + } + return SendRectArm64(receiver, selector); + } [DllImport(ObjC, EntryPoint = "objc_msgSend")] private static extern CGSize SendSize(nint receiver, nint selector); diff --git a/tests/Filee.App.Tests/EnginesPageTests.cs b/tests/Filee.App.Tests/EnginesPageTests.cs index dcc491a4f..813b0c69f 100644 --- a/tests/Filee.App.Tests/EnginesPageTests.cs +++ b/tests/Filee.App.Tests/EnginesPageTests.cs @@ -113,15 +113,12 @@ public void Only_LibreOffice_has_settings_and_they_change_the_saved_switches() } [AvaloniaFact] - public void Tesseract_package_has_install_available_when_not_installed() + public void Tesseract_package_exists_and_offers_own_copy_or_guidance() { TestServices.EnsureInitialized("en"); var downloads = AppHost.Get(); var tesseract = downloads.Packages.FirstOrDefault(p => p.Package.Id == "tesseract"); Assert.NotNull(tesseract); - if (EnginePackageState.CanSystemInstall(tesseract.Package)) - { - Assert.True(tesseract.CanInstall || tesseract.IsInstalled); - } + Assert.True(tesseract.CanUseOwnCopy || tesseract.IsInstalled); } } diff --git a/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs b/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs index 1a6f16e64..528efd98b 100644 --- a/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs +++ b/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs @@ -1,10 +1,15 @@ +// Tests for OcrEngineRegistry, OcrPageResult, and OCR document options. + using Filee.Core.Ocr; using Filee.Core.Presets; namespace Filee.Core.Tests; -public class OcrEngineRegistryTests +public class OcrEngineRegistryTests : IDisposable { + public OcrEngineRegistryTests() => OcrEngineRegistry.ResetForTesting(); + public void Dispose() => OcrEngineRegistry.ResetForTesting(); + private sealed class MockOcrEngine(string id, string name, bool available, IReadOnlyList languages) : IOcrEngine { public string Id => id; @@ -54,14 +59,14 @@ public void Register_and_retrieve_engine() public void DocumentOptions_defaults_and_cloning() { var options = new DocumentOptions(); - Assert.True(options.Ocr); + Assert.False(options.Ocr); Assert.Null(options.OcrLanguage); - options.Ocr = false; + options.Ocr = true; options.OcrLanguage = "ne"; var clone = options.Clone(); - Assert.False(clone.Ocr); + Assert.True(clone.Ocr); Assert.Equal("ne", clone.OcrLanguage); } diff --git a/tests/Filee.Core.Tests/UserDataStoreTests.cs b/tests/Filee.Core.Tests/UserDataStoreTests.cs index 142da41e2..111f201d3 100644 --- a/tests/Filee.Core.Tests/UserDataStoreTests.cs +++ b/tests/Filee.Core.Tests/UserDataStoreTests.cs @@ -415,7 +415,7 @@ public void Version_1_settings_are_upgraded_to_the_current_engine_list() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", "subtitle", "wic", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", "subtitle", "wic", "image-ocr", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } @@ -467,7 +467,7 @@ public void Version_4_libraries_get_spreadsheet_and_presentation_profiles() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", "subtitle", "wic", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", "subtitle", "wic", "image-ocr", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); var ids = store.Profiles.Select(p => p.Id).ToList(); Assert.Equal(ids.IndexOf("office") + 1, ids.IndexOf("spreadsheets")); Assert.Equal(ids.IndexOf("office") + 2, ids.IndexOf("presentations")); @@ -642,8 +642,8 @@ public void Version_13_settings_get_the_data_engine_after_email() // ... and, from schema 15, the 3D model engine before Pandoc (no CAD or archive engine in this list), from // schema 18 the PDF slides engine there too (no PDF text engine), from schema 21 the WIC engine (no FFmpeg), - // from schema 25 the geospatial engine after the 3D models engine. - Assert.Equal(["magick", "email", "data", "vector", "model3d", "geo", "pdf-slides", "wic", "pandoc", "libreoffice"], store.Settings.EnginePriority); + // from schema 25 the geospatial engine after the 3D models engine, from schema 33 the image OCR engine before Pandoc. + Assert.Equal(["magick", "email", "data", "vector", "model3d", "geo", "pdf-slides", "wic", "image-ocr", "pandoc", "libreoffice"], store.Settings.EnginePriority); } [Fact] @@ -927,7 +927,7 @@ public void Version_24_settings_get_the_geo_engine_after_the_3d_models_engine() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "cad", "model3d", "geo", "archive", "pandoc", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "cad", "model3d", "geo", "archive", "image-ocr", "pandoc", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } @@ -1181,7 +1181,7 @@ public void Version_17_settings_get_the_pdf_slides_engine_after_pdf_text() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "pdfium", "docx-writer", "pdf-text", "pdf-slides", "email", "geo", "wic", "pandoc", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "pdfium", "docx-writer", "pdf-text", "pdf-slides", "email", "geo", "wic", "image-ocr", "pandoc", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } @@ -1196,7 +1196,21 @@ public void Version_20_settings_get_the_wic_engine_after_the_other_built_in_engi store.Load(); // Not near the top: the HWPX writer stays among the ranks that win ties (see EbookTests). - Assert.Equal(["magick", "pdfium", "hwpx-writer", "subtitle", "wic", "ffmpeg", "geo", "pandoc", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "pdfium", "hwpx-writer", "subtitle", "wic", "image-ocr", "ffmpeg", "geo", "pandoc", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); + } + + [Fact] + public void Version_32_settings_get_the_image_ocr_engine_before_ffmpeg() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "settings.json"), + """{ "schemaVersion": 32, "enginePriority": ["magick", "pdfium", "ffmpeg", "pandoc", "libreoffice"] }"""); + + var store = new UserDataStore(dir.Path); + store.Load(); + + Assert.Equal(["magick", "pdfium", "image-ocr", "ffmpeg", "pandoc", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } diff --git a/tests/Filee.Engines.Tests/TesseractOcrTests.cs b/tests/Filee.Engines.Tests/TesseractOcrTests.cs index b5fe537ab..01135b71b 100644 --- a/tests/Filee.Engines.Tests/TesseractOcrTests.cs +++ b/tests/Filee.Engines.Tests/TesseractOcrTests.cs @@ -1,3 +1,5 @@ +// Tests for TesseractOcrEngine language mapping and image recognition. + using Filee.Core.Ocr; using Filee.Engines.Ocr; using SkiaSharp; @@ -19,8 +21,7 @@ public void MapLanguage_maps_known_codes(string? filee, string tesseract) => [Fact] public void MapLanguage_returns_null_for_a_code_with_no_trained_data() { - // Unlike the old PaddleOCR engine, which silently fell back to English for any code it didn't - // recognize, an unmapped code must be reported as unsupported rather than guessed at. + // An unmapped code must be reported as unsupported rather than guessed at. Assert.Null(TesseractOcrEngine.MapLanguage("xal")); } diff --git a/tests/Filee.Platform.Tests/VisionOcrTests.cs b/tests/Filee.Platform.Tests/VisionOcrTests.cs index de906b166..68c7bc4e9 100644 --- a/tests/Filee.Platform.Tests/VisionOcrTests.cs +++ b/tests/Filee.Platform.Tests/VisionOcrTests.cs @@ -1,3 +1,5 @@ +// Tests for Apple Vision OCR engine and macOS OCR pipelines. + using Filee.Core.Conversion; using Filee.Core.Ocr; using Filee.Core.Presets; @@ -68,12 +70,13 @@ public async Task ImageOcrConverterConvertsImageToTextAndDocx() try { + var ocrPreset = new Preset { Document = new DocumentOptions { Ocr = true } }; var txtOut = Path.Combine(workDir, "banner.txt"); var txtStep = new ConversionStep( InputPath: imagePath, From: "jpg", To: "txt", - Preset: new Preset(), + Preset: ocrPreset, Output: new FixedPath(txtOut), WorkDirectory: workDir); @@ -88,7 +91,7 @@ public async Task ImageOcrConverterConvertsImageToTextAndDocx() InputPath: imagePath, From: "jpg", To: "docx", - Preset: new Preset(), + Preset: ocrPreset, Output: new FixedPath(docxOut), WorkDirectory: workDir); @@ -139,7 +142,7 @@ public void PdfDocumentReaderExtractsEditableTextFromScannedPdf() // Read the scanned PDF using PdfDocumentReader with OCR enabled var mediaFolder = Path.Combine(workDir, "media"); - var document = PdfDocumentReader.Read(pdfPath, mediaFolder, engine); + var document = PdfDocumentReader.Read(pdfPath, mediaFolder, engine, enableOcr: true, workDirectory: workDir); Assert.NotEmpty(document.Sections); var paragraphs = document.Sections.SelectMany(s => s.Blocks).OfType().ToList(); @@ -149,7 +152,7 @@ public void PdfDocumentReaderExtractsEditableTextFromScannedPdf() Assert.Contains("Filee", allText, StringComparison.OrdinalIgnoreCase); // Verify plain text reader also extracts the OCR text - var plainDoc = PdfDocumentReader.ReadText(pdfPath, engine); + var plainDoc = PdfDocumentReader.ReadText(pdfPath, engine, enableOcr: true, workDirectory: workDir); var plainText = PdfDocumentReader.PlainText(plainDoc); Assert.Contains("Filee", plainText, StringComparison.OrdinalIgnoreCase); } From 0cb5e5629a9b397a84259dfb20aed3617ab7ae95 Mon Sep 17 00:00:00 2001 From: smjrifle Date: Thu, 22 Oct 2026 21:56:58 +0545 Subject: [PATCH 4/8] fix(ocr): clean temp scans on text-only and ungate standalone image ocr --- src/Filee.Engines/Ocr/ImageOcrConverter.cs | 6 +++--- src/Filee.Engines/Pdf/PdfDocumentReader.cs | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Filee.Engines/Ocr/ImageOcrConverter.cs b/src/Filee.Engines/Ocr/ImageOcrConverter.cs index 259c10497..5ca18689a 100644 --- a/src/Filee.Engines/Ocr/ImageOcrConverter.cs +++ b/src/Filee.Engines/Ocr/ImageOcrConverter.cs @@ -40,9 +40,9 @@ public EngineStatus GetStatus() public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { - if (!step.Preset.Document.Ocr) - throw new InvalidOperationException("OCR is disabled in preset settings."); - + // Unlike PdfDocumentReader (where OCR is one option among several for an already-readable document), + // this converter exists only because OCR is the sole way to get text out of an image: Document.Ocr + // (off by default, so an ordinary PDF conversion isn't surprised by OCR) does not gate it. var language = step.Preset.Document.OcrLanguage; var engine = OcrEngineRegistry.GetDefault(language); if (engine is null || !engine.IsAvailable) diff --git a/src/Filee.Engines/Pdf/PdfDocumentReader.cs b/src/Filee.Engines/Pdf/PdfDocumentReader.cs index 073377417..e5460307d 100644 --- a/src/Filee.Engines/Pdf/PdfDocumentReader.cs +++ b/src/Filee.Engines/Pdf/PdfDocumentReader.cs @@ -412,7 +412,9 @@ private async Task ReadPageAsync(Page page, CancellationToken cance } finally { - if (isTempScan && words.Count > 0 && File.Exists(scanPath)) + // Kept only when it's about to become content.ScanPath below (words.Count == 0, !_textOnly); + // every other outcome (OCR succeeded, or _textOnly never looks at ScanPath at all) is done with it. + if (isTempScan && (words.Count > 0 || _textOnly) && File.Exists(scanPath)) { try { File.Delete(scanPath); } catch { } } From 286a937a3dd6680a5baa1c16ccfb1e6eb3c7e3e1 Mon Sep 17 00:00:00 2001 From: smjrifle Date: Thu, 22 Oct 2026 22:06:31 +0545 Subject: [PATCH 5/8] test(ocr): add regression tests for default preset image ocr and temp scan cleanup --- tests/Filee.Platform.Tests/VisionOcrTests.cs | 88 ++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/Filee.Platform.Tests/VisionOcrTests.cs b/tests/Filee.Platform.Tests/VisionOcrTests.cs index 68c7bc4e9..2c05a3656 100644 --- a/tests/Filee.Platform.Tests/VisionOcrTests.cs +++ b/tests/Filee.Platform.Tests/VisionOcrTests.cs @@ -194,6 +194,94 @@ public void ScannedPdfWithoutOcrStaysPictureOnly() } } + [Fact] + public async Task ImageOcrConverterSucceedsWithDefaultPresetWhenDocumentOcrIsFalse() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS Vision framework."); + return; + } + + var engine = new MacOsVisionOcrEngine(); + OcrEngineRegistry.Register(engine); + + var converter = new ImageOcrConverter(); + var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); + var workDir = Path.Combine(Path.GetTempPath(), $"filee-test-default-preset-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + + try + { + var defaultPreset = new Preset(); + Assert.False(defaultPreset.Document.Ocr); + + var txtOut = Path.Combine(workDir, "banner-default.txt"); + var txtStep = new ConversionStep( + InputPath: imagePath, + From: "jpg", + To: "txt", + Preset: defaultPreset, + Output: new FixedPath(txtOut), + WorkDirectory: workDir); + + var outputs = await converter.ConvertAsync(txtStep, null, TestContext.Current.CancellationToken); + Assert.Single(outputs); + Assert.True(File.Exists(outputs[0])); + var txt = await File.ReadAllTextAsync(outputs[0], TestContext.Current.CancellationToken); + Assert.Contains("Filee", txt, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + + [Fact] + public void PdfDocumentReaderDeletesTempScanInTextOnlyModeWhenOcrReturnsNoWords() + { + var emptyEngine = new EmptyMockEngine(); + var workDir = Path.Combine(Path.GetTempPath(), $"filee-pdf-leak-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + + try + { + var pdfPath = Path.Combine(workDir, "blank-scanned.pdf"); + using (var pdfDoc = new PdfSharp.Pdf.PdfDocument()) + { + var page = pdfDoc.AddPage(); + page.Width = PdfSharp.Drawing.XUnit.FromPoint(200); + page.Height = PdfSharp.Drawing.XUnit.FromPoint(200); + using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page); + gfx.DrawRectangle(PdfSharp.Drawing.XBrushes.White, 0, 0, 200, 200); + pdfDoc.Save(pdfPath); + } + + var scanDir = Path.Combine(workDir, "pdf-scans"); + var plainDoc = PdfDocumentReader.ReadText(pdfPath, emptyEngine, enableOcr: true, workDirectory: workDir); + + if (Directory.Exists(scanDir)) + { + var leftoverFiles = Directory.GetFiles(scanDir, "*.png"); + Assert.Empty(leftoverFiles); + } + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + + private sealed class EmptyMockEngine : IOcrEngine + { + public string Id => "empty-mock"; + public string DisplayName => "Empty Mock"; + public bool IsAvailable => true; + public IReadOnlyList SupportedLanguages => ["en"]; + public Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default) => + Task.FromResult(OcrPageResult.Empty(100, 100)); + } + private sealed class FixedPath(string path) : IOutputAllocator { public string? Allocate(string extension, string? suffix = null) => path; From adc71c3f3b253e0746ed6450dfc7ea3c1031f6d1 Mon Sep 17 00:00:00 2001 From: smjrifle Date: Thu, 22 Oct 2026 23:01:46 +0545 Subject: [PATCH 6/8] i18n(ocr): add missing OCR translation keys to fr, ne, and zh-TW --- src/Filee.App/Assets/i18n/fr.json | 14 ++++++++++++++ src/Filee.App/Assets/i18n/ne.json | 14 ++++++++++++++ src/Filee.App/Assets/i18n/zh-TW.json | 14 ++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 321978517..e8b738b43 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -147,6 +147,7 @@ "engine.ghostscript.description": "EPS, PostScript et anciens fichiers Illustrator en PDF et images ; PDF en EPS et PS.", "engine.calibre.description": "Formats de livres numériques rares (LIT, LRF, CHM, PDB, RB…) et sortie Kindle (MOBI, AZW3).\nPlacé au-dessus du rédacteur HWPX, il produit aussi les PDF des livres numériques (bien plus vite pour les longs livres).", "engine.libreoffice.description": "Lit les formats anciens et moins courants : DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nEnregistre aussi en DOC, XLS, PPT, ODT, ODP, modèles OpenDocument et EMF.", + "engine.image-ocr.description": "Extrait le texte des PDF scannés et des images vers des documents modifiables (Word, texte, HWPX).", "profile.images": "Images", "profile.pdf": "PDF", @@ -534,6 +535,14 @@ "presets.leave_out_pictures": "Omettre les images (texte seul)", "presets.leave_out_pictures_hint": "Désactivé : les images sont enregistrées dans un dossier « nom_files » à côté du fichier Markdown, qui y renvoie par des liens.\nActivé : le texte seul, sans dossier.", "presets.target_folder": "Dossier (extraire les archives)", + "presets.group.ocr": "Reconnaissance optique de caractères (OCR)", + "presets.ocr_enabled": "Reconnaître le texte dans les scans et les images", + "presets.ocr_hint": "Extrait le texte modifiable des pages scannées et des images vers Word, texte et documents.", + "presets.ocr_language": "Langue de reconnaissance", + "presets.ocr_language_hint": "Langue utilisée pour reconnaître précisément les caractères et les mots.", + "presets.ocr_language.auto": "Automatique / Par défaut du système", + "presets.ocr_engine_required": "Nécessite Tesseract. Installez-le dans Paramètres → Moteurs", + "presets.ocr_disabled_linux": "Linux n'a pas d'OCR intégré. Installez Tesseract dans Paramètres → Moteurs pour l'activer.", "presets.group.media": "Vidéo et audio", "presets.media_quality": "Qualité", "presets.media_quality_hint": "Quand le nouveau format peut contenir les codecs tels quels (MOV d'iPhone → MP4), la vidéo change seulement de conteneur.\nAucune perte de qualité, HDR conservé, terminé en quelques secondes.\nPetit réencode toujours pour alléger le fichier.", @@ -669,6 +678,11 @@ "engines.guidance.linux.libreoffice": "Installez-le avec votre gestionnaire de paquets (Ubuntu/Debian : sudo apt install libreoffice), puis choisissez « Revérifier ».\nL'ouverture des HWP nécessite aussi Java et l'extension H2Orestart.", "engines.guidance.linux.ghostscript": "Installez-le avec votre gestionnaire de paquets (Ubuntu/Debian : sudo apt install ghostscript), puis choisissez « Revérifier ».", "engines.guidance.windows": "Filee ne propose pas de téléchargement de {0} pour ce PC.\nChoisissez « Utiliser ma copie… » et sélectionnez une copie installée.", + "engines.package.tesseract.name": "Tesseract (reconnaissance de texte multilingue)", + "engines.package.tesseract.description": "Reconnaît le texte dans plus de 100 langues, y compris le devanagari (népalais/hindi), l'arabe et des écritures rares.\nSur macOS, Apple Vision fonctionne nativement pour l'anglais, le CJK et les langues européennes sans lui.", + "engines.guidance.macos.tesseract": "Installez Tesseract avec Homebrew (brew install tesseract tesseract-lang), puis choisissez « Revérifier ».\nOu choisissez « Utiliser ma copie… » et sélectionnez son dossier bin.", + "engines.guidance.linux.tesseract": "Installez Tesseract avec votre gestionnaire de paquets (Ubuntu/Debian : sudo apt install tesseract-ocr tesseract-ocr-all), puis choisissez « Revérifier ».\nOu choisissez « Utiliser ma copie… » et sélectionnez son dossier de programme.", + "engines.guidance.windows.tesseract": "Installez Tesseract depuis UB-Mannheim (github.com/UB-Mannheim/tesseract), puis choisissez « Revérifier » ou sélectionnez son dossier d'installation.", "engines.use_own_app_folder": "Choisissez le dossier contenant {0}.app", "engines.progress_detail": "{0} sur {1} · {2}/s · {3}", "engines.eta.estimating": "estimation du temps restant…", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index af33cae24..807d60c12 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -140,6 +140,7 @@ "engine.ghostscript.description": "EPS, PostScript र पुराना Illustrator फाइलहरूलाई PDF र छविहरूमा; PDF लाई EPS र PS मा।", "engine.calibre.description": "दुर्लभ इ-पुस्तक ढाँचाहरू (LIT, LRF, CHM, PDB, RB…) र Kindle आउटपुट (MOBI, AZW3)।\nHWPX writer भन्दा माथि राख्दा, यसले इ-पुस्तकका PDF पनि बनाउँछ (लामो पुस्तकका लागि धेरै छिटो)।", "engine.libreoffice.description": "पुराना र कम प्रचलित ढाँचाहरू पढ्छ: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nसाथै DOC, XLS, PPT, ODT, ODP, OpenDocument टेम्प्लेट र EMF को रूपमा सुरक्षित गर्दछ।", + "engine.image-ocr.description": "स्क्यान गरिएका PDF र तस्बिरहरूबाट पाठ निकालेर सम्पादन योग्य कागजात (Word, पाठ, HWPX) बनाउँछ।", "profile.images": "छविहरू", "profile.pdf": "PDF", "profile.office": "कार्यालय कागजातहरू", @@ -519,6 +520,14 @@ "presets.leave_out_pictures": "तस्बिरहरू छोड्नुहोस् (पाठ मात्र)", "presets.leave_out_pictures_hint": "बन्द: तस्बिरहरू Markdown फाइलको छेउमा \"name_files\" फोल्डरमा सुरक्षित हुन्छन् र त्यहीँबाट लिङ्क हुन्छन्।\nचालु: केवल पाठ मात्र, कुनै फोल्डर छैन।", "presets.target_folder": "फोल्डर (अभिलेख निकाल्न)", + "presets.group.ocr": "अप्टिकल क्यारेक्टर रिकग्निसन (OCR)", + "presets.ocr_enabled": "स्क्यान र तस्बिरहरूमा पाठ चिन्नुहोस्", + "presets.ocr_hint": "स्क्यान गरिएका पृष्ठ र तस्बिरहरूबाट सम्पादन योग्य पाठ निकालेर Word, पाठ र कागजातमा राख्छ।", + "presets.ocr_language": "पहिचान भाषा", + "presets.ocr_language_hint": "अक्षर र शब्दहरू सही रूपमा चिन्नका लागि प्रयोग हुने भाषा।", + "presets.ocr_language.auto": "स्वचालित / प्रणाली पूर्वनिर्धारित", + "presets.ocr_engine_required": "Tesseract आवश्यक छ। यसलाई सेटिङहरू → इन्जिनहरूमा स्थापना गर्नुहोस्", + "presets.ocr_disabled_linux": "Linux मा अन्तर्निहित OCR छैन। सक्षम गर्न सेटिङहरू → इन्जिनहरूमा Tesseract स्थापना गर्नुहोस्।", "presets.group.media": "भिडियो र अडियो", "presets.media_quality": "गुणस्तर", "presets.media_quality_hint": "जब नयाँ ढाँचाले कोडेकहरूलाई जस्ताको तस्तै राख्न सक्छ (iPhone MOV → MP4), भिडियोलाई केवल पुन: र्‍याप गरिन्छ।\nकुनै गुणस्तर घट्दैन, HDR रहन्छ, सेकेन्डमै सकिन्छ।\nसानोले फाइल घटाउन सधैं पुन: इन्कोड गर्दछ।", @@ -652,6 +661,11 @@ "engines.guidance.linux.libreoffice": "यसलाई प्याकेज प्रबन्धकबाट स्थापना गर्नुहोस् (sudo apt install libreoffice), त्यसपछि \"फेरि जाँच गर्नुहोस्\" रोज्नुहोस्।", "engines.guidance.linux.ghostscript": "यसलाई प्याकेज प्रबन्धकबाट स्थापना गर्नुहोस् (sudo apt install ghostscript), त्यसपछि \"फेरि जाँच गर्नुहोस्\" रोज्नुहोस्।", "engines.guidance.windows": "यस कम्प्युटरका लागि Filee सँग {0} को डाउनलोड छैन।\n“मेरो प्रतिलिपि प्रयोग गर्नुहोस्…” रोज्नुहोस् र स्थापना गरिएको प्रतिलिपि चयन गर्नुहोस्।", + "engines.package.tesseract.name": "Tesseract (बहुभाषिक पाठ पहिचान)", + "engines.package.tesseract.description": "देवनागरी (नेपाली/हिन्दी), अरबी र दुर्लभ लिपिहरू सहित १०० भन्दा बढी भाषामा पाठ चिन्छ।\nmacOS मा, Apple Vision ले यो बिना नै अंग्रेजी, CJK र युरोपेली भाषाहरूका लागि मौलिक रूपमा काम गर्छ।", + "engines.guidance.macos.tesseract": "Homebrew बाट Tesseract स्थापना गर्नुहोस् (brew install tesseract tesseract-lang), त्यसपछि “फेरि जाँच गर्नुहोस्” रोज्नुहोस्।\nवा “मेरो प्रतिलिपि प्रयोग गर्नुहोस्…” रोजेर यसको bin फोल्डर चयन गर्नुहोस्।", + "engines.guidance.linux.tesseract": "आफ्नो प्याकेज म्यानेजरबाट Tesseract स्थापना गर्नुहोस् (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), त्यसपछि “फेरि जाँच गर्नुहोस्” रोज्नुहोस्।\nवा “मेरो प्रतिलिपि प्रयोग गर्नुहोस्…” रोजेर यसको प्रोग्राम फोल्डर चयन गर्नुहोस्।", + "engines.guidance.windows.tesseract": "UB-Mannheim (github.com/UB-Mannheim/tesseract) बाट Tesseract स्थापना गर्नुहोस्, त्यसपछि “फेरि जाँच गर्नुहोस्” रोज्नुहोस् वा यसको स्थापना फोल्डर चयन गर्नुहोस्।", "engines.use_own_app_folder": "{0}.app समावेश भएको फोल्डर रोज्नुहोस्", "engines.progress_detail": "{1} मध्ये {0} · {2}/s · {3}", "engines.eta.estimating": "अनुमान गरिँदैछ…", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index d1a197a11..8a53826a7 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -147,6 +147,7 @@ "engine.ghostscript.description": "EPS、PostScript 和舊版 Illustrator 檔案轉為 PDF 和圖片;PDF 轉為 EPS 和 PS。", "engine.calibre.description": "少見的電子書格式(LIT、LRF、CHM、PDB、RB…)以及 Kindle 輸出(MOBI、AZW3)。\n排在 HWPX writer 之上時,也由它把電子書轉為 PDF(長書快得多)。", "engine.libreoffice.description": "讀取較舊或少見的格式,如 DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visio。\n也能儲存為 DOC、XLS、PPT、ODT、ODP、OpenDocument 範本或 EMF。", + "engine.image-ocr.description": "從掃描的 PDF 與圖片擷取文字,轉成可編輯的文件(Word、文字檔、HWPX)。", "profile.images": "圖片", "profile.pdf": "PDF", @@ -534,6 +535,14 @@ "presets.leave_out_pictures": "不保留圖片(僅文字)", "presets.leave_out_pictures_hint": "關閉時,圖片儲存在 Markdown 檔案旁的「名稱_files」資料夾中,並從檔案中連結。\n開啟時,只保留文字,不產生資料夾。", "presets.target_folder": "資料夾(解壓縮)", + "presets.group.ocr": "光學字元辨識(OCR)", + "presets.ocr_enabled": "辨識掃描檔與圖片中的文字", + "presets.ocr_hint": "從掃描頁面與圖片擷取可編輯的文字,存成 Word、文字檔或文件。", + "presets.ocr_language": "辨識語言", + "presets.ocr_language_hint": "用於準確辨識文字與單字的語言。", + "presets.ocr_language.auto": "自動/系統預設", + "presets.ocr_engine_required": "需要 Tesseract,請在「設定 → 轉換引擎」中安裝", + "presets.ocr_disabled_linux": "Linux 沒有內建 OCR。請在「設定 → 轉換引擎」中安裝 Tesseract 以啟用。", "presets.group.media": "影片和音訊", "presets.media_quality": "品質", "presets.media_quality_hint": "新格式能直接容納原編碼時(如 iPhone MOV → MP4),只重新封裝。\n畫質無損、保留 HDR,幾秒即可完成。\n「小」總會重新編碼以減小檔案。", @@ -669,6 +678,11 @@ "engines.guidance.linux.libreoffice": "用套件管理員安裝(Ubuntu/Debian 下執行 sudo apt install libreoffice)後點「重新檢查」。\n開啟 HWP 還需要 Java 和 H2Orestart 擴充功能。", "engines.guidance.linux.ghostscript": "用套件管理員安裝(Ubuntu/Debian 下執行 sudo apt install ghostscript)後點「重新檢查」。", "engines.guidance.windows": "沒有適用於這台電腦的 {0} 下載。\n點「使用我的程式…」選擇已安裝的程式。", + "engines.package.tesseract.name": "Tesseract(多語言文字辨識)", + "engines.package.tesseract.description": "可辨識超過 100 種語言的文字,包括天城文(尼泊爾文/印地文)、阿拉伯文及少見文字。\n在 macOS 上,Apple Vision 原生支援英文、中日韓與歐洲語言,無需安裝它。", + "engines.guidance.macos.tesseract": "用 Homebrew 安裝 Tesseract(brew install tesseract tesseract-lang),然後點「重新檢查」。\n或點「使用我的程式…」並選擇其 bin 資料夾。", + "engines.guidance.linux.tesseract": "用套件管理員安裝 Tesseract(Ubuntu/Debian:sudo apt install tesseract-ocr tesseract-ocr-all),然後點「重新檢查」。\n或點「使用我的程式…」並選擇其程式資料夾。", + "engines.guidance.windows.tesseract": "從 UB-Mannheim(github.com/UB-Mannheim/tesseract)安裝 Tesseract,然後點「重新檢查」或選擇其安裝資料夾。", "engines.use_own_app_folder": "選擇包含 {0}.app 的資料夾", "engines.progress_detail": "{0} / {1} · {2}/s · {3}", "engines.eta.estimating": "正在估算剩餘時間…", From fc691da9d7ba08dafc0798572f8149d208850e93 Mon Sep 17 00:00:00 2001 From: smjrifle Date: Thu, 22 Oct 2026 23:09:54 +0545 Subject: [PATCH 7/8] style(engines): collapse CanDownload back to single line --- src/Filee.Engines/Infrastructure/EngineDownloads.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Filee.Engines/Infrastructure/EngineDownloads.cs b/src/Filee.Engines/Infrastructure/EngineDownloads.cs index 4a1506842..106af3bf8 100644 --- a/src/Filee.Engines/Infrastructure/EngineDownloads.cs +++ b/src/Filee.Engines/Infrastructure/EngineDownloads.cs @@ -53,8 +53,7 @@ public static class EngineDownloads /// Total download size of a package in bytes. public static long DownloadSize(EnginePackage package) => package.Components.Sum(c => Components.GetValueOrDefault(c)?.Size ?? 0); - public static bool CanDownload(EnginePackage package) => - package.Components.Count > 0 && package.Components.All(Components.ContainsKey); + public static bool CanDownload(EnginePackage package) => package.Components.Count > 0 && package.Components.All(Components.ContainsKey); public static string? UnavailableReason(EnginePackage package) { From f7170a52da1f25b2a4e38237999dc9e40d5c518d Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 21:27:31 +0900 Subject: [PATCH 8/8] OCR: Apple Vision on macOS on top of #429's Tesseract step #429 (Tesseract, languages downloaded when used, "Searchable PDF") is the OCR base now, so this branch keeps only the Apple Vision part and plugs it into #429's OCR step. The own OCR layer (IOcrEngine, OcrEngineRegistry, ImageOcrConverter, TesseractOcrEngine, the Document OCR option, schema 33) is gone. - VisionConverter: the same OCR edges as TesseractConverter (pictures and PDFs -> searchable PDF), registered before it, so on a Mac the route planner takes Vision; unavailable on Windows and Linux. A preset language Vision doesn't read goes to Tesseract when it is installed, and is refused with error.ocr_needs_tesseract otherwise. - AppleVision: the Objective-C runtime bridge from this PR, moved into Filee.Engines: newest revision, languages asked from the Mac, word boxes (boundingBoxForRange), cancel, objc_msgSend_stret on Intel, releases. - OcrPdfWriter: the page picture with invisible text (render mode 3) in a glyphless TrueType font built in code; read like Tesseract's layer. - PDFs share TesseractConverter.RecognizePdfAsync (pages with text copied). - Preset editor: no download size for the languages Vision reads, a macOS hint. - 3 new strings in all 14 languages; docs/ENGINES.md. No settings migration. Co-authored-by: smjrifle Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 18 +- docs/ENGINES.md | 30 ++ src/Filee.App/Assets/i18n/ar.json | 17 +- src/Filee.App/Assets/i18n/en.json | 17 +- src/Filee.App/Assets/i18n/es.json | 17 +- src/Filee.App/Assets/i18n/fr.json | 17 +- src/Filee.App/Assets/i18n/hi.json | 17 +- src/Filee.App/Assets/i18n/ja.json | 17 +- src/Filee.App/Assets/i18n/ko.json | 17 +- src/Filee.App/Assets/i18n/ne.json | 17 +- src/Filee.App/Assets/i18n/pl.json | 17 +- src/Filee.App/Assets/i18n/pt-BR.json | 17 +- src/Filee.App/Assets/i18n/ru.json | 17 +- src/Filee.App/Assets/i18n/th.json | 3 + src/Filee.App/Assets/i18n/zh-CN.json | 17 +- src/Filee.App/Assets/i18n/zh-TW.json | 17 +- src/Filee.App/Services/AppHost.cs | 3 - .../Services/EngineDownloadService.cs | 8 +- .../ViewModels/PresetEditorViewModel.cs | 9 +- src/Filee.App/Views/PresetEditorView.axaml | 36 +- src/Filee.Cli/Filee.Cli.csproj | 1 - src/Filee.Core/Ocr/IOcrEngine.cs | 30 -- src/Filee.Core/Ocr/OcrEngineRegistry.cs | 144 ------ src/Filee.Core/Ocr/OcrResult.cs | 39 -- src/Filee.Core/Presets/Preset.cs | 10 - src/Filee.Core/Settings/UserDataStore.cs | 4 - src/Filee.Engines/EngineRegistry.cs | 1 + src/Filee.Engines/Filee.Engines.csproj | 1 - .../Infrastructure/EngineInstaller.cs | 4 +- src/Filee.Engines/Ocr/AppleVision.cs | 247 ++++++++++ src/Filee.Engines/Ocr/ImageOcrConverter.cs | 96 ---- src/Filee.Engines/Ocr/OcrTextLayer.cs | 373 ++++++++++++++ src/Filee.Engines/Ocr/TesseractConverter.cs | 18 +- src/Filee.Engines/Ocr/TesseractOcrEngine.cs | 242 ---------- src/Filee.Engines/Ocr/VisionConverter.cs | 180 +++++++ src/Filee.Engines/Ocr/VisionLanguages.cs | 59 +++ .../MacOsVisionOcrEngine.cs | 302 ------------ tests/Filee.App.Tests/EnginesPageTests.cs | 10 - tests/Filee.App.Tests/RenderTests.cs | 4 + .../OcrEngineRegistryTests.cs | 93 ---- tests/Filee.Core.Tests/UserDataStoreTests.cs | 28 +- tests/Filee.Engines.Tests/OcrTests.cs | 5 +- .../Filee.Engines.Tests/TesseractOcrTests.cs | 75 --- tests/Filee.Engines.Tests/VisionOcrTests.cs | 457 ++++++++++++++++++ .../Filee.Platform.Tests.csproj | 1 - tests/Filee.Platform.Tests/VisionOcrTests.cs | 289 ----------- 46 files changed, 1433 insertions(+), 1608 deletions(-) delete mode 100644 src/Filee.Core/Ocr/IOcrEngine.cs delete mode 100644 src/Filee.Core/Ocr/OcrEngineRegistry.cs delete mode 100644 src/Filee.Core/Ocr/OcrResult.cs create mode 100644 src/Filee.Engines/Ocr/AppleVision.cs delete mode 100644 src/Filee.Engines/Ocr/ImageOcrConverter.cs create mode 100644 src/Filee.Engines/Ocr/OcrTextLayer.cs delete mode 100644 src/Filee.Engines/Ocr/TesseractOcrEngine.cs create mode 100644 src/Filee.Engines/Ocr/VisionConverter.cs create mode 100644 src/Filee.Engines/Ocr/VisionLanguages.cs delete mode 100644 src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs delete mode 100644 tests/Filee.Core.Tests/OcrEngineRegistryTests.cs delete mode 100644 tests/Filee.Engines.Tests/TesseractOcrTests.cs create mode 100644 tests/Filee.Engines.Tests/VisionOcrTests.cs delete mode 100644 tests/Filee.Platform.Tests/VisionOcrTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6198d989..5cd601a26 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,17 +16,15 @@ ┌──────────────────────────── Filee.Core ───────────────────────────────────────────┐ │ JobQueue ──► RoutePlanner (Dijkstra over converter edges) ──► IConverter steps │ │ FormatRegistry · Preset · OutputPathResolver · ToolbarProfile · ProfileSelector │ - │ OcrEngineRegistry · IOcrEngine (Apple Vision on macOS, Tesseract elsewhere) │ - └──────────────────────────────────────────────┬──────────────────────────────────┘ + └──────────────────────────────────────────────┬──────────────────────────────────────┘ ▼ ┌──────────────────────────── Filee.Engines ────────────────────────────────────────┐ │ Built in: Magick (images, RAW) · PdfSharp · Pdfium · PdfPig · Svg.Skia · ICNS │ │ HwpxWriter + DocxWriter over DocumentReaders (DOCX, XLSX, XLS, ODS, PPTX, │ │ PDF, HTML, EPUB, MOBI, FB2, HWPX, Markdown) · Spreadsheets · OOXML variants │ - │ · E-mail · Data · E-books · CAD · Fonts · Subtitles · 3D models · Image OCR │ + │ · E-mail · Data · E-books · CAD · Fonts · Subtitles · 3D models │ │ Bundled: rhwp (HWP→PDF, HWP↔HWPX) · 7-Zip (archives) · Unhwp · Markdig │ │ Optional: FFmpeg · LibreOffice + H2Orestart · calibre · Ghostscript · Pandoc │ - │ · Tesseract OCR │ └────────────────────────────────────────────────────────────────────────────────────┘ ``` @@ -61,18 +59,6 @@ families to `FileResult.MissingFonts`; the toast card can offer one trusted source, while the CLI and watch jobs report the names without pausing conversion or installing anything. -## Optical Character Recognition (OCR) - -Filee provides engine-neutral OCR abstractions in `Filee.Core.Ocr` (`IOcrEngine`, `OcrEngineRegistry`, `OcrPageResult`, `OcrLine`, `OcrWord`, `OcrRect`). - -- **Engine Discovery**: - - `OcrEngineRegistry` discovers available OCR engines without requiring callers to couple to platform-specific projects. - - On macOS, `MacOsVisionOcrEngine` (in `Filee.Platform.MacOS`) calls Apple's Vision framework (`VNRecognizeTextRequest`) via Objective-C runtime P/Invoke (`objc_msgSend` on arm64, `objc_msgSend_stret` for 32-byte `CGRect` on x86_64, with `setAutomaticallyDetectsLanguage:` on macOS 13+). It is discovered via reflection at runtime so Windows and Linux builds remain decoupled. - - Across all desktop platforms, `TesseractOcrEngine` (in `Filee.Engines.Ocr`) runs Tesseract CLI out-of-process with timeouts and argument lists, supporting 100+ languages including Devanagari (Nepali/Hindi), Indic, Cyrillic, Thai, and Arabic scripts. -- **Conversion Pipelines**: - - `ImageOcrConverter`: converts raster images directly to TXT, DOCX, and Markdown (`image-ocr` engine priority entry). - - `PdfDocumentReader`: when `DocumentOptions.Ocr` is enabled, runs OCR on scanned pages. Scanned PDFs with an existing invisible OCR layer (`TextRenderingMode.Neither`) use that layer directly when OCR is enabled, while preserving the scanned page picture when OCR is disabled. Rotated pages (`rotated && !_textOnly`) preserve their rendered scan picture. - ## Files dragged from a web browser A picture, a link to a file or a download dragged out of a browser opens the donut like a file from the file manager. diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 40cc80c9c..493bc290b 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -33,6 +33,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Pandoc | **optional download** (~42 MB, 240 MB on disk) | Markdown ↔ DOCX/ODT/RTF, DOCX/ODT/HTML/RTF → Markdown, HTML ↔ DOCX/ODT, markup languages (reStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter, Textile, man, OPML) ↔ Markdown/HTML/DOCX/ODT and each other (and → RTF, EPUB, TXT), Markdown and markup → PPTX slides | | Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it | | Tesseract | **optional download** (~50 MB, 160 MB on disk; each language 0.4–10 MB when first used) | **text recognition (OCR)**: scans and pictures → searchable PDF, and their text into DOCX, TXT, Markdown and HWPX ("Recognize text" in the preset, see below) | +| Apple Vision | part of macOS (Mac only) | **text recognition (OCR) on a Mac**, the same step as Tesseract without a download; Tesseract reads the languages Vision doesn't (see "Apple Vision on macOS") | | FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, Live / Motion Photos → their video, a clip of a video → an iPhone Live Photo or Android Motion Photo (experimental), images → HEIC, clips or audio files joined into one, pictures → a slideshow MP4, copies without metadata (see "Media tools") | | Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR; e-books → PDF (ranked above the HWPX writer, see below) | @@ -1557,3 +1558,32 @@ scans and pictures with Tesseract (`Ocr/TesseractConverter.cs`), in the language - The language data is copied into the job's folder and passed with `--tessdata-dir`, so a system Tesseract reads only Filee's pinned files; `-c tessedit_create_pdf=1` replaces the `pdf` config file that lives in Tesseract's own tessdata folder. + +### Apple Vision on macOS + +On a Mac the same step runs with Apple Vision (`Ocr/VisionConverter.cs`, `VNRecognizeTextRequest` at the accurate +level and the newest revision the Mac has), part of macOS, so nothing is downloaded. It has the same OCR edges as +Tesseract and is registered before it, so the route planner takes it; on Windows and Linux it is unavailable +("Not available on this platform"). + +- The preset's language (a Tesseract code) is mapped to Vision's identifiers (`VisionLanguages`) and asked for with + English next to it; if Vision refuses the pair it reads with the language alone. Which languages Vision reads comes + from the Mac itself (`supportedRecognitionLanguagesAndReturnError:`, more with newer macOS versions); a language it + doesn't read (Hindi, Nepali, Greek, …) goes to Tesseract when that is installed, and is refused otherwise with + "Apple Vision can't read this language. Install Tesseract …". The preset editor shows no download size for the + languages Vision reads, and its hint names Apple Vision. +- PDFs go page by page exactly like Tesseract's (`TesseractConverter.RecognizePdfAsync`, at most two pages at a time). + A JPEG picture goes into the PDF as it is, other pictures (and a JPEG that EXIF turns) as an upright PNG; every page + of a TIFF becomes a page. The page size comes from the picture's resolution, 300 dpi when it has none. +- Each page is the picture with the recognized words as invisible text (`OcrPdfWriter`, render mode 3): one font + size per line (its height), the baseline at the line's bottom, each word stretched (`Tz`) to where the next one + starts with its space, so search, selection and copying follow the picture. Vision gives a box per word + (`boundingBoxForRange:error:`); without one the words are spread over the line by their length. +- The text is set in a TrueType font with only empty glyphs, built in code (`GlyphlessFont`): every UTF-16 unit is + one character code (`Identity-H`), shown as glyph 1, and the `ToUnicode` map gives it back unchanged, so any + script works without a font in the file. `PdfDocumentReader` and `PagesWithText` read the layer like Tesseract's. +- Vision is called through the Objective-C runtime (`Ocr/AppleVision.cs`), without a bridging library. A `CGRect` + result needs `objc_msgSend_stret` on Intel Macs; objects made with `alloc`/`init` are released, the rest go with an + autorelease pool per page. A cancelled job cancels the running request. +- Tests: `VisionLanguageTests`, `OcrTextLayerTests` and `VisionConverterTests` (a stand-in recognizer) run everywhere; + `AppleVisionTests` read real pictures on macOS (CI's Apple silicon and Intel jobs). diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 0b7b10362..299504c63 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -80,6 +80,7 @@ "error.cannot_turn": "التدوير والانعكاس متاحان للصور فقط", "error.no_transparency": "هذا التنسيق لا يدعم الشفافية، لذا لا يوجد ما يُقص", "error.ocr_language_download": "تعذّر تنزيل لغة التعرّف الضوئي OCR", + "error.ocr_needs_tesseract": "لا يستطيع Apple Vision قراءة هذه اللغة. ثبّت Tesseract من الإعدادات ← المحركات", "error.too_many_frames": "عدد الإطارات كبير جدًا لحفظها كصور. زِد قيمة «كل إطار رقم N»", "error.too_many_pictures": "عدد الصور كبير جدًا لفيديو واحد، قسّمها على عدة فيديوهات", "error.not_enough_space": "لا توجد مساحة كافية على القرص لحفظ الصور", @@ -147,9 +148,9 @@ "engine.pandoc.description": "التحويل بين Markdown وHTML وبين ODT وRTF، ومن DOCX إلى Markdown\nالتحويل بين reStructuredText وLaTeX وOrg وAsciiDoc وTypst وMediaWiki وDocBook وJupyter وغيرها وبين Markdown وHTML وDOCX وODT\nتحويل Markdown وOrg إلى شرائح PPTX", "engine.ghostscript.description": "تحويل EPS وPostScript وملفات Illustrator القديمة إلى PDF وصور؛ وتحويل PDF إلى EPS وPS.", "engine.tesseract.description": "يتعرّف على نص الصور والمستندات الممسوحة (OCR): PDF قابل للبحث وDOCX وTXT.", + "engine.vision.description": "يتعرّف على نص الصور والمستندات الممسوحة بأداة التعرّف على النص المدمجة في macOS (OCR): PDF قابل للبحث وDOCX وTXT. لا حاجة إلى أي تنزيل.", "engine.calibre.description": "صيغ الكتب الإلكترونية النادرة (LIT وLRF وCHM وPDB وRB…) وإخراج Kindle ‏(MOBI وAZW3).\nعند وضعه فوق محرك كتابة HWPX، يُنشئ أيضًا PDF من الكتب الإلكترونية (بسرعة أكبر بكثير للكتب الطويلة).", "engine.libreoffice.description": "قراءة الصيغ القديمة والأقل شيوعًا: DOC وPPT وPages وKeynote وNumbers وWordPerfect وClarisWorks وPublisher وVisio…\nوالحفظ بصيغ DOC وXLS وPPT وODT وODP وقوالب OpenDocument وEMF.", - "engine.image-ocr.description": "استخراج النص من ملفات PDF الممسوحة ضوئيًا والصور إلى مستندات قابلة للتحرير (Word والنص وHWPX).", "profile.images": "الصور", "profile.pdf": "PDF", "profile.office": "المستندات", @@ -538,6 +539,7 @@ "presets.pdf_ghostscript_hint": "غالبًا ما يُنتج حجمًا أصغر، لكنه يعيد كتابة الملف بالكامل.\nتُحفَظ النتيجة الأصغر.", "presets.ocr": "التعرّف على النص (OCR)", "presets.ocr_hint": "يصبح نص المستندات الممسوحة والصور قابلًا للبحث والتحرير. يتطلب Tesseract (الإعدادات ← المحركات).\nتبقى الصفحات التي تحتوي على نص كما هي.", + "presets.ocr_hint_macos": "يصبح نص المستندات الممسوحة والصور قابلًا للبحث والتحرير بأداة التعرّف على النص المدمجة في macOS (Apple Vision). اللغات التي لا تقرؤها تتطلب Tesseract (الإعدادات ← المحركات).\nتبقى الصفحات التي تحتوي على نص كما هي.", "presets.ocr_language": "لغة النص", "presets.ocr_language_size": "تنزيل {0}", "presets.pdf_keep_metadata": "الاحتفاظ بالبيانات الوصفية للصفحات والصور (XMP)", @@ -551,14 +553,6 @@ "presets.leave_out_pictures": "استبعاد الصور (النص فقط)", "presets.leave_out_pictures_hint": "عند التعطيل: تُحفَظ الصور في مجلد name_files بجانب ملف Markdown، وتُدرَج روابطها فيه.\nعند التفعيل: النص فقط، دون مجلد.", "presets.target_folder": "مجلد (استخراج الأرشيفات)", - "presets.group.ocr": "التعرف البصري على الأحرف (OCR)", - "presets.ocr_enabled": "التعرف على النص في المستندات الممسوحة ضوئيًا والصور", - "presets.ocr_hint": "استخراج نص قابل للتحرير من الصفحات الممسوحة ضوئيًا والصور إلى Word والنص والمستندات.", - "presets.ocr_language": "لغة التعرف", - "presets.ocr_language_hint": "اللغة المستخدمة للتعرف بدقة على الأحرف والكلمات.", - "presets.ocr_language.auto": "تلقائي / الافتراضي للنظام", - "presets.ocr_engine_required": "يتطلب Tesseract. قم بتثبيته من الإعدادات ← المحركات", - "presets.ocr_disabled_linux": "لا يحتوي Linux على OCR مدمج. قم بتثبيت Tesseract من الإعدادات ← المحركات للتمكين.", "presets.group.media": "الفيديو والصوت", "presets.media_quality": "الجودة", "presets.media_quality_hint": "عندما تستطيع الصيغة الجديدة الاحتفاظ بالترميزات كما هي (MOV من iPhone إلى MP4)، تتغير الحاوية فقط.\nدون فقدان جودة، مع الاحتفاظ بـ HDR، خلال ثوانٍ.\nخيار «صغير» يعيد الترميز دائمًا لتقليل الحجم.", @@ -696,11 +690,6 @@ "engines.guidance.linux.ghostscript": "ثبّته باستخدام مدير الحزم (على Ubuntu/Debian: sudo apt install ghostscript)، ثم اختر «التحقق مجددًا».", "engines.guidance.linux.tesseract": "ثبّته باستخدام مدير الحزم (على Ubuntu/Debian: sudo apt install tesseract-ocr)، ثم اختر «التحقق مجددًا».", "engines.guidance.windows": "لا يوفر Filee تنزيلًا لـ {0} لهذا الحاسوب.\nاختر «استخدام نسختي…» وحدّد نسخة مثبّتة.", - "engines.package.tesseract.name": "Tesseract (التعرف على النصوص متعدد اللغات)", - "engines.package.tesseract.description": "يتعرف على النص بأكثر من 100 لغة، بما في ذلك الديفاناغارية (النيبالية/الهندية) والعربية والخطوط النادرة.\nعلى macOS، يعمل Apple Vision أصلاً للغات الإنجليزية ولغات CJK واللغات الأوروبية بدونه.", - "engines.guidance.macos.tesseract": "قم بتثبيت Tesseract باستخدام Homebrew (brew install tesseract tesseract-lang)، ثم اختر “تحقق مرة أخرى”.\nأو اختر “استخدام نسختي…” وحدد مجلد bin الخاص به.", - "engines.guidance.linux.tesseract": "قم بتثبيت Tesseract باستخدام مدير الحزم (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)، ثم اختر “تحقق مرة أخرى”.\nأو اختر “استخدام نسختي…” وحدد مجلد البرنامج الخاص به.", - "engines.guidance.windows.tesseract": "قم بتثبيت Tesseract من UB-Mannheim (github.com/UB-Mannheim/tesseract)، ثم اختر “تحقق مرة أخرى” أو حدد مجلد التثبيت الخاص به.", "engines.use_own_app_folder": "اختر المجلد الذي يحتوي على {0}.app", "engines.progress_detail": "{0} من {1} · {2}/ث · {3}", "engines.eta.estimating": "جارٍ تقدير الوقت المتبقي…", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 43b21f349..c69036151 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Rotate and flip work on images only", "error.no_transparency": "This format has no transparency, so there's nothing to trim", "error.ocr_language_download": "Couldn't download the OCR language", + "error.ocr_needs_tesseract": "Apple Vision can't read this language. Install Tesseract in Settings → Engines", "error.too_many_frames": "Too many frames to save as pictures, raise \"Every Nth frame\"", "error.too_many_pictures": "Too many pictures for one video, split them into several", "error.not_enough_space": "Not enough free disk space for the pictures", @@ -154,9 +155,9 @@ "engine.pandoc.description": "Markdown and HTML ↔ ODT and RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter and more ↔ Markdown, HTML, DOCX and ODT\nMarkdown and Org → PPTX slides", "engine.ghostscript.description": "EPS, PostScript and older Illustrator files to PDF and images; PDF to EPS and PS.", "engine.tesseract.description": "Recognizes the text of scans and pictures (OCR): searchable PDF, DOCX, TXT.", + "engine.vision.description": "Recognizes the text of scans and pictures with macOS's own text recognition (OCR): searchable PDF, DOCX, TXT. Nothing to download.", "engine.calibre.description": "Rare e-book formats (LIT, LRF, CHM, PDB, RB…) and Kindle output (MOBI, AZW3).\nAbove the HWPX writer, it also makes the PDFs of e-books (much faster for long books).", "engine.libreoffice.description": "Reads older and less common formats: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nAlso saves as DOC, XLS, PPT, ODT, ODP, OpenDocument templates and EMF.", - "engine.image-ocr.description": "Extracts text from scanned PDFs and pictures into editable documents (Word, text, HWPX).", "profile.images": "Images", "profile.pdf": "PDF", @@ -553,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Often smaller, but it rewrites the whole file.\nThe smallest result is kept.", "presets.ocr": "Recognize text (OCR)", "presets.ocr_hint": "Scans and pictures get text you can search and edit. Needs Tesseract (Settings → Engines).\nPages that already have text stay as they are.", + "presets.ocr_hint_macos": "Scans and pictures get text you can search and edit, with macOS's own text recognition (Apple Vision). Languages it can't read need Tesseract (Settings → Engines).\nPages that already have text stay as they are.", "presets.ocr_language": "Language of the text", "presets.ocr_language_size": "{0} download", "presets.pdf_keep_metadata": "Keep page and picture metadata (XMP)", @@ -566,14 +568,6 @@ "presets.leave_out_pictures": "Leave out pictures (text only)", "presets.leave_out_pictures_hint": "Off: the pictures are saved in a \"name_files\" folder next to the Markdown file and linked from it.\nOn: only the text, no folder.", "presets.target_folder": "Folder (extract archives)", - "presets.group.ocr": "Optical Character Recognition (OCR)", - "presets.ocr_enabled": "Recognize text in scans and images", - "presets.ocr_hint": "Extracts editable text from scanned pages and images into Word, text, and documents.", - "presets.ocr_language": "Recognition language", - "presets.ocr_language_hint": "Language used to recognize characters and words accurately.", - "presets.ocr_language.auto": "Automatic / System Default", - "presets.ocr_engine_required": "Needs Tesseract. Install it in Settings → Engines", - "presets.ocr_disabled_linux": "Linux has no built-in OCR. Install Tesseract in Settings → Engines to enable.", "presets.group.media": "Video & audio", "presets.media_quality": "Quality", "presets.media_quality_hint": "When the new format can hold the codecs as they are (iPhone MOV → MP4), the video is only rewrapped.\nNo quality lost, HDR kept, done in seconds.\nSmall always re-encodes to make the file smaller.", @@ -713,11 +707,6 @@ "engines.guidance.linux.ghostscript": "Install it with your package manager (Ubuntu/Debian: sudo apt install ghostscript), then choose “Check again”.", "engines.guidance.linux.tesseract": "Install it with your package manager (Ubuntu/Debian: sudo apt install tesseract-ocr), then choose “Check again”.", "engines.guidance.windows": "Filee has no download of {0} for this PC.\nChoose “Use my copy…” and select an installed copy.", - "engines.package.tesseract.name": "Tesseract (multilingual text recognition)", - "engines.package.tesseract.description": "Recognizes text in over 100 languages, including Devanagari (Nepali/Hindi), Arabic, and rare scripts.\nOn macOS, Apple Vision works natively for English, CJK, and European languages without it.", - "engines.guidance.macos.tesseract": "Install Tesseract with Homebrew (brew install tesseract tesseract-lang), then choose “Check again”.\nOr choose “Use my copy…” and select its bin folder.", - "engines.guidance.linux.tesseract": "Install Tesseract with your package manager (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), then choose “Check again”.\nOr choose “Use my copy…” and select its program folder.", - "engines.guidance.windows.tesseract": "Install Tesseract from UB-Mannheim (github.com/UB-Mannheim/tesseract), then choose “Check again” or select its install folder.", "engines.use_own_app_folder": "Choose the folder containing {0}.app", "engines.progress_detail": "{0} of {1} · {2}/s · {3}", "engines.eta.estimating": "estimating time left…", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index fe9054c2f..616ba8401 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -80,6 +80,7 @@ "error.cannot_turn": "Rotar y voltear solo funcionan con imágenes", "error.no_transparency": "Este formato no tiene transparencia, así que no hay nada que recortar", "error.ocr_language_download": "No se pudo descargar el idioma de OCR", + "error.ocr_needs_tesseract": "Apple Vision no puede leer este idioma. Instala Tesseract en Ajustes → Motores", "error.too_many_frames": "Demasiados fotogramas para guardar como imágenes; aumenta «Cada N fotogramas»", "error.too_many_pictures": "Demasiadas imágenes para un vídeo; divídelas en varios", "error.not_enough_space": "Espacio libre en disco insuficiente para las imágenes", @@ -147,9 +148,9 @@ "engine.pandoc.description": "Markdown y HTML ↔ ODT y RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter y más ↔ Markdown, HTML, DOCX y ODT\nMarkdown y Org → diapositivas PPTX", "engine.ghostscript.description": "EPS, PostScript y archivos antiguos de Illustrator a PDF e imágenes; PDF a EPS y PS.", "engine.tesseract.description": "Reconoce el texto de escaneos e imágenes (OCR): PDF con búsqueda, DOCX, TXT.", + "engine.vision.description": "Reconoce el texto de escaneos e imágenes con el reconocimiento de texto propio de macOS (OCR): PDF con búsqueda, DOCX, TXT. No hay nada que descargar.", "engine.calibre.description": "Formatos raros de libros electrónicos (LIT, LRF, CHM, PDB, RB…) y salida Kindle (MOBI, AZW3).\nPor encima del escritor HWPX, también crea los PDF de libros electrónicos (mucho más rápido para libros largos).", "engine.libreoffice.description": "Lee formatos antiguos y poco habituales: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nTambién guarda como DOC, XLS, PPT, ODT, ODP, plantillas OpenDocument y EMF.", - "engine.image-ocr.description": "Extrae texto de PDF escaneados e imágenes a documentos editables (Word, texto, HWPX).", "profile.images": "Imágenes", "profile.pdf": "PDF", "profile.office": "Documentos", @@ -538,6 +539,7 @@ "presets.pdf_ghostscript_hint": "A menudo es más pequeño, pero reescribe todo el archivo.\nSe conserva el resultado más pequeño.", "presets.ocr": "Reconocer texto (OCR)", "presets.ocr_hint": "Los escaneos e imágenes tendrán texto que se puede buscar y editar. Requiere Tesseract (Ajustes → Motores).\nLas páginas que ya tienen texto no cambian.", + "presets.ocr_hint_macos": "Los escaneos e imágenes tendrán texto que se puede buscar y editar, con el reconocimiento de texto propio de macOS (Apple Vision). Los idiomas que no lee requieren Tesseract (Ajustes → Motores).\nLas páginas que ya tienen texto no cambian.", "presets.ocr_language": "Idioma del texto", "presets.ocr_language_size": "Descarga de {0}", "presets.pdf_keep_metadata": "Conservar metadatos de página e imagen (XMP)", @@ -551,14 +553,6 @@ "presets.leave_out_pictures": "Omitir imágenes (solo texto)", "presets.leave_out_pictures_hint": "Desactivado: las imágenes se guardan en una carpeta \"nombre_files\" junto al archivo Markdown y se enlazan desde él.\nActivado: solo el texto, sin carpeta.", "presets.target_folder": "Carpeta (extraer archivos comprimidos)", - "presets.group.ocr": "Reconocimiento óptico de caracteres (OCR)", - "presets.ocr_enabled": "Reconocer texto en escaneos e imágenes", - "presets.ocr_hint": "Extrae texto editable de páginas escaneadas e imágenes a Word, texto y documentos.", - "presets.ocr_language": "Idioma de reconocimiento", - "presets.ocr_language_hint": "Idioma utilizado para reconocer caracteres y palabras con precisión.", - "presets.ocr_language.auto": "Automático / Predeterminado del sistema", - "presets.ocr_engine_required": "Requiere Tesseract. Instálalo en Configuración → Motores", - "presets.ocr_disabled_linux": "Linux no tiene OCR integrado. Instala Tesseract en Configuración → Motores para activarlo.", "presets.group.media": "Vídeo y audio", "presets.media_quality": "Calidad", "presets.media_quality_hint": "Cuando el formato nuevo puede conservar los códecs tal como están (MOV de iPhone → MP4), el vídeo solo se cambia de contenedor sin recodificar.\nSin pérdida de calidad, HDR conservado, listo en segundos.\nPequeño siempre recodifica para que el archivo pese menos.", @@ -696,11 +690,6 @@ "engines.guidance.linux.ghostscript": "Instálalo con tu gestor de paquetes (Ubuntu/Debian: sudo apt install ghostscript) y luego elige «Comprobar otra vez».", "engines.guidance.linux.tesseract": "Instálalo con tu gestor de paquetes (Ubuntu/Debian: sudo apt install tesseract-ocr) y luego elige «Comprobar otra vez».", "engines.guidance.windows": "Filee no tiene una descarga de {0} para este PC.\nElige «Usar mi copia…» y selecciona una copia instalada.", - "engines.package.tesseract.name": "Tesseract (reconocimiento de texto multilingüe)", - "engines.package.tesseract.description": "Reconoce texto en más de 100 idiomas, incluidos devanagari (nepalí/hindi), árabe y alfabetos poco comunes.\nEn macOS, Apple Vision funciona de forma nativa para inglés, CJK e idiomas europeos sin él.", - "engines.guidance.macos.tesseract": "Instala Tesseract con Homebrew (brew install tesseract tesseract-lang), luego elige “Comprobar de nuevo”.\nO elige “Usar mi copia…” y selecciona su carpeta bin.", - "engines.guidance.linux.tesseract": "Instala Tesseract con el gestor de paquetes (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), luego elige “Comprobar de nuevo”.\nO elige “Usar mi copia…” y selecciona su carpeta de programas.", - "engines.guidance.windows.tesseract": "Instala Tesseract desde UB-Mannheim (github.com/UB-Mannheim/tesseract), luego elige “Comprobar de nuevo” o selecciona su carpeta de instalación.", "engines.use_own_app_folder": "Elige la carpeta que contiene {0}.app", "engines.progress_detail": "{0} de {1} · {2}/s · {3}", "engines.eta.estimating": "calculando el tiempo restante…", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index ac6e904fa..b98709092 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -86,6 +86,7 @@ "error.cannot_turn": "La rotation et le retournement ne s'appliquent qu'aux images", "error.no_transparency": "Ce format n'a pas de transparence : il n'y a rien à rogner", "error.ocr_language_download": "Impossible de télécharger la langue de l'OCR", + "error.ocr_needs_tesseract": "Apple Vision ne lit pas cette langue. Installez Tesseract dans Paramètres → Moteurs", "error.too_many_frames": "Trop d'images à enregistrer, augmentez « Une image sur N »", "error.too_many_pictures": "Trop d'images pour une seule vidéo, répartissez-les en plusieurs", "error.not_enough_space": "Pas assez d'espace disque libre pour les images", @@ -154,9 +155,9 @@ "engine.pandoc.description": "Markdown et HTML ↔ ODT et RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter et plus ↔ Markdown, HTML, DOCX et ODT\nMarkdown et Org → diapositives PPTX", "engine.ghostscript.description": "EPS, PostScript et anciens fichiers Illustrator en PDF et images ; PDF en EPS et PS.", "engine.tesseract.description": "Reconnaît le texte des numérisations et des images (OCR) : PDF consultable, DOCX, TXT.", + "engine.vision.description": "Reconnaît le texte des numérisations et des images avec la reconnaissance de texte intégrée à macOS (OCR) : PDF consultable, DOCX, TXT. Rien à télécharger.", "engine.calibre.description": "Formats de livres numériques rares (LIT, LRF, CHM, PDB, RB…) et sortie Kindle (MOBI, AZW3).\nPlacé au-dessus du rédacteur HWPX, il produit aussi les PDF des livres numériques (bien plus vite pour les longs livres).", "engine.libreoffice.description": "Lit les formats anciens et moins courants : DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nEnregistre aussi en DOC, XLS, PPT, ODT, ODP, modèles OpenDocument et EMF.", - "engine.image-ocr.description": "Extrait le texte des PDF scannés et des images vers des documents modifiables (Word, texte, HWPX).", "profile.images": "Images", "profile.pdf": "PDF", @@ -553,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Souvent plus léger, mais il réécrit tout le fichier.\nLe résultat le plus léger est conservé.", "presets.ocr": "Reconnaître le texte (OCR)", "presets.ocr_hint": "Le texte des numérisations et des images devient consultable et modifiable. Nécessite Tesseract (Paramètres → Moteurs).\nLes pages qui ont déjà du texte restent telles quelles.", + "presets.ocr_hint_macos": "Le texte des numérisations et des images devient consultable et modifiable grâce à la reconnaissance de texte intégrée à macOS (Apple Vision). Les langues qu'elle ne lit pas nécessitent Tesseract (Paramètres → Moteurs).\nLes pages qui ont déjà du texte restent telles quelles.", "presets.ocr_language": "Langue du texte", "presets.ocr_language_size": "Téléchargement de {0}", "presets.pdf_keep_metadata": "Conserver les métadonnées des pages et des images (XMP)", @@ -566,14 +568,6 @@ "presets.leave_out_pictures": "Omettre les images (texte seul)", "presets.leave_out_pictures_hint": "Désactivé : les images sont enregistrées dans un dossier « nom_files » à côté du fichier Markdown, qui y renvoie par des liens.\nActivé : le texte seul, sans dossier.", "presets.target_folder": "Dossier (extraire les archives)", - "presets.group.ocr": "Reconnaissance optique de caractères (OCR)", - "presets.ocr_enabled": "Reconnaître le texte dans les scans et les images", - "presets.ocr_hint": "Extrait le texte modifiable des pages scannées et des images vers Word, texte et documents.", - "presets.ocr_language": "Langue de reconnaissance", - "presets.ocr_language_hint": "Langue utilisée pour reconnaître précisément les caractères et les mots.", - "presets.ocr_language.auto": "Automatique / Par défaut du système", - "presets.ocr_engine_required": "Nécessite Tesseract. Installez-le dans Paramètres → Moteurs", - "presets.ocr_disabled_linux": "Linux n'a pas d'OCR intégré. Installez Tesseract dans Paramètres → Moteurs pour l'activer.", "presets.group.media": "Vidéo et audio", "presets.media_quality": "Qualité", "presets.media_quality_hint": "Quand le nouveau format peut contenir les codecs tels quels (MOV d'iPhone → MP4), la vidéo change seulement de conteneur.\nAucune perte de qualité, HDR conservé, terminé en quelques secondes.\nPetit réencode toujours pour alléger le fichier.", @@ -713,11 +707,6 @@ "engines.guidance.linux.ghostscript": "Installez-le avec votre gestionnaire de paquets (Ubuntu/Debian : sudo apt install ghostscript), puis choisissez « Revérifier ».", "engines.guidance.linux.tesseract": "Installez-le avec votre gestionnaire de paquets (Ubuntu/Debian : sudo apt install tesseract-ocr), puis choisissez « Revérifier ».", "engines.guidance.windows": "Filee ne propose pas de téléchargement de {0} pour ce PC.\nChoisissez « Utiliser ma copie… » et sélectionnez une copie installée.", - "engines.package.tesseract.name": "Tesseract (reconnaissance de texte multilingue)", - "engines.package.tesseract.description": "Reconnaît le texte dans plus de 100 langues, y compris le devanagari (népalais/hindi), l'arabe et des écritures rares.\nSur macOS, Apple Vision fonctionne nativement pour l'anglais, le CJK et les langues européennes sans lui.", - "engines.guidance.macos.tesseract": "Installez Tesseract avec Homebrew (brew install tesseract tesseract-lang), puis choisissez « Revérifier ».\nOu choisissez « Utiliser ma copie… » et sélectionnez son dossier bin.", - "engines.guidance.linux.tesseract": "Installez Tesseract avec votre gestionnaire de paquets (Ubuntu/Debian : sudo apt install tesseract-ocr tesseract-ocr-all), puis choisissez « Revérifier ».\nOu choisissez « Utiliser ma copie… » et sélectionnez son dossier de programme.", - "engines.guidance.windows.tesseract": "Installez Tesseract depuis UB-Mannheim (github.com/UB-Mannheim/tesseract), puis choisissez « Revérifier » ou sélectionnez son dossier d'installation.", "engines.use_own_app_folder": "Choisissez le dossier contenant {0}.app", "engines.progress_detail": "{0} sur {1} · {2}/s · {3}", "engines.eta.estimating": "estimation du temps restant…", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index ebd900f86..f97d9e4bc 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -80,6 +80,7 @@ "error.cannot_turn": "घुमाना और पलटना केवल इमेज पर काम करता है", "error.no_transparency": "इस फ़ॉर्मैट में पारदर्शिता नहीं है, इसलिए काटने को कुछ नहीं है", "error.ocr_language_download": "OCR भाषा डाउनलोड नहीं हो सकी", + "error.ocr_needs_tesseract": "Apple Vision यह भाषा नहीं पढ़ सकता। सेटिंग्स → इंजन में Tesseract इंस्टॉल करें", "error.too_many_frames": "तस्वीरों के रूप में सहेजने के लिए बहुत ज़्यादा फ़्रेम हैं, \"हर Nवाँ फ़्रेम\" बढ़ाएँ", "error.too_many_pictures": "एक वीडियो के लिए तस्वीरें बहुत ज़्यादा हैं, इन्हें कई हिस्सों में बाँटें", "error.not_enough_space": "तस्वीरों के लिए डिस्क पर पर्याप्त खाली जगह नहीं है", @@ -147,9 +148,9 @@ "engine.pandoc.description": "Markdown और HTML ↔ ODT और RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter और अन्य ↔ Markdown, HTML, DOCX और ODT\nMarkdown और Org → PPTX स्लाइड", "engine.ghostscript.description": "EPS, PostScript और पुरानी Illustrator फ़ाइलें PDF और इमेज में; PDF से EPS और PS।", "engine.tesseract.description": "स्कैन और तस्वीरों का टेक्स्ट पहचानता है (OCR): खोजने योग्य PDF, DOCX, TXT।", + "engine.vision.description": "macOS की अपनी टेक्स्ट पहचान से स्कैन और तस्वीरों का टेक्स्ट पहचानता है (OCR): खोजने योग्य PDF, DOCX, TXT। कुछ डाउनलोड नहीं करना पड़ता।", "engine.calibre.description": "दुर्लभ ई-बुक फ़ॉर्मैट (LIT, LRF, CHM, PDB, RB…) और Kindle आउटपुट (MOBI, AZW3)।\nHWPX writer के ऊपर होने पर यह ई-बुक के PDF भी बनाता है (लंबी किताबों के लिए बहुत तेज़)।", "engine.libreoffice.description": "पुराने और कम प्रचलित फ़ॉर्मैट पढ़ता है: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nDOC, XLS, PPT, ODT, ODP, OpenDocument टेम्पलेट और EMF के रूप में सहेजता भी है।", - "engine.image-ocr.description": "स्कैन किए गए PDF और चित्रों से टेक्स्ट निकाल कर संपादन योग्य दस्तावेज़ों (Word, टेक्स्ट, HWPX) में बदलता है।", "profile.images": "इमेज", "profile.pdf": "PDF", "profile.office": "दस्तावेज़", @@ -538,6 +539,7 @@ "presets.pdf_ghostscript_hint": "अक्सर और छोटा होता है, लेकिन पूरी फ़ाइल को दोबारा लिखता है।\nसबसे छोटा नतीजा रखा जाता है।", "presets.ocr": "टेक्स्ट पहचानें (OCR)", "presets.ocr_hint": "स्कैन और तस्वीरों का टेक्स्ट खोजने और बदलने योग्य बनता है। Tesseract चाहिए (सेटिंग्स → इंजन)।\nजिन पेजों में पहले से टेक्स्ट है वे वैसे ही रहते हैं।", + "presets.ocr_hint_macos": "macOS की अपनी टेक्स्ट पहचान (Apple Vision) से स्कैन और तस्वीरों का टेक्स्ट खोजने और बदलने योग्य बनता है। जो भाषाएँ यह नहीं पढ़ सकता, उनके लिए Tesseract चाहिए (सेटिंग्स → इंजन)।\nजिन पेजों में पहले से टेक्स्ट है वे वैसे ही रहते हैं।", "presets.ocr_language": "टेक्स्ट की भाषा", "presets.ocr_language_size": "{0} डाउनलोड", "presets.pdf_keep_metadata": "पेज और तस्वीर का मेटाडेटा (XMP) रखें", @@ -551,14 +553,6 @@ "presets.leave_out_pictures": "तस्वीरें छोड़ दें (केवल टेक्स्ट)", "presets.leave_out_pictures_hint": "बंद: तस्वीरें Markdown फ़ाइल के बगल में \"name_files\" फ़ोल्डर में सहेजी जाती हैं और उसी से लिंक होती हैं।\nचालू: केवल टेक्स्ट, कोई फ़ोल्डर नहीं।", "presets.target_folder": "फ़ोल्डर (आर्काइव निकालने के लिए)", - "presets.group.ocr": "ऑप्टिकल कैरेक्टर रिकग्निशन (OCR)", - "presets.ocr_enabled": "स्कैन और छवियों में टेक्स्ट पहचानें", - "presets.ocr_hint": "स्कैन किए गए पेजों और छवियों से संपादन योग्य टेक्स्ट निकाल कर Word, टेक्स्ट और दस्तावेज़ों में बदलें।", - "presets.ocr_language": "पहचान भाषा", - "presets.ocr_language_hint": "वर्णों और शब्दों को सटीक रूप से पहचानने के लिए उपयोग की जाने वाली भाषा।", - "presets.ocr_language.auto": "स्वचालित / सिस्टम डिफ़ॉल्ट", - "presets.ocr_engine_required": "Tesseract की आवश्यकता है। इसे सेटिंग्स → इंजन में इंस्टॉल करें", - "presets.ocr_disabled_linux": "Linux में कोई अंतर्निहित OCR नहीं है। सक्षम करने के लिए सेटिंग्स → इंजन में Tesseract इंस्टॉल करें।", "presets.group.media": "वीडियो और ऑडियो", "presets.media_quality": "क्वालिटी", "presets.media_quality_hint": "जब नया फ़ॉर्मैट कोडेक को वैसे ही रख सकता है (iPhone MOV → MP4), तो वीडियो केवल दोबारा लपेटा जाता है।\nकोई क्वालिटी नहीं घटती, HDR बना रहता है, और कुछ सेकंड में हो जाता है।\nछोटा विकल्प फ़ाइल घटाने के लिए हमेशा दोबारा एन्कोड करता है।", @@ -696,11 +690,6 @@ "engines.guidance.linux.ghostscript": "इसे अपने पैकेज मैनेजर से इंस्टॉल करें (Ubuntu/Debian: sudo apt install ghostscript), फिर “फिर से जाँचें” चुनें।", "engines.guidance.linux.tesseract": "इसे अपने पैकेज मैनेजर से इंस्टॉल करें (Ubuntu/Debian: sudo apt install tesseract-ocr), फिर “फिर से जाँचें” चुनें।", "engines.guidance.windows": "इस PC के लिए Filee के पास {0} का डाउनलोड नहीं है।\n“मेरी कॉपी इस्तेमाल करें…” चुनें और इंस्टॉल की गई कॉपी चुनें।", - "engines.package.tesseract.name": "Tesseract (बहुभाषी टेक्स्ट पहचान)", - "engines.package.tesseract.description": "देवनागरी (नेपाली/हिंदी), अरबी और दुर्लभ लिपियों सहित 100 से अधिक भाषाओं में टेक्स्ट पहचानता है।\nmacOS पर Apple Vision अंग्रेजी, CJK और यूरोपीय भाषाओं के लिए बिना इसके भी काम करता है।", - "engines.guidance.macos.tesseract": "Homebrew से Tesseract इंस्टॉल करें (brew install tesseract tesseract-lang), फिर “दोबारा जांचें” चुनें।\nया “मेरी कॉपी इस्तेमाल करें…” चुनें और इसके bin फ़ोल्डर का चयन करें।", - "engines.guidance.linux.tesseract": "अपने पैकेज मैनेजर से Tesseract इंस्टॉल करें (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), फिर “दोबारा जांचें” चुनें।\nया “मेरी कॉपी इस्तेमाल करें…” चुनें और इसका प्रोग्राम फ़ोल्डर चुनें।", - "engines.guidance.windows.tesseract": "UB-Mannheim (github.com/UB-Mannheim/tesseract) से Tesseract इंस्टॉल करें, फिर “दोबारा जांचें” चुनें या इसके इंस्टाल फ़ोल्डर का चयन करें।", "engines.use_own_app_folder": "वह फ़ोल्डर चुनें जिसमें {0}.app है", "engines.progress_detail": "{1} में से {0} · {2}/s · {3}", "engines.eta.estimating": "बचा हुआ समय आँका जा रहा है…", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 9cfd74de3..026c77bed 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -80,6 +80,7 @@ "error.cannot_turn": "回転と反転は画像でのみ使えます", "error.no_transparency": "この形式には透明部分がないため、切り取るものがありません", "error.ocr_language_download": "OCR の言語データをダウンロードできませんでした", + "error.ocr_needs_tesseract": "Apple Vision はこの言語を読めません。「設定」→「変換エンジン」で Tesseract をインストールしてください", "error.too_many_frames": "画像として保存するフレーム数が多すぎます。「Nフレームごと」の値を大きくしてください", "error.too_many_pictures": "1本の動画にするには画像が多すぎます。いくつかに分けてください", "error.not_enough_space": "画像を保存するための空き容量が不足しています", @@ -147,9 +148,9 @@ "engine.pandoc.description": "Markdown、HTMLとODT、RTFを相互変換し、DOCXをMarkdownに変換します。\nreStructuredText、LaTeX、Org、AsciiDoc、Typst、MediaWiki、DocBook、JupyterなどをMarkdown、HTML、DOCX、ODTと相互変換します。\nMarkdown、OrgからPPTXスライドを作成します。", "engine.ghostscript.description": "EPS、PostScript、古いIllustratorファイルをPDFや画像に変換し、PDFをEPSやPSに変換します。", "engine.tesseract.description": "スキャンや画像の文字を認識します(OCR):検索可能な PDF、DOCX、TXT。", + "engine.vision.description": "macOS 標準の文字認識で、スキャンや画像の文字を認識します(OCR):検索可能な PDF、DOCX、TXT。ダウンロードは不要です。", "engine.calibre.description": "一般的でない電子書籍形式(LIT、LRF、CHM、PDB、RBなど)を変換し、Kindle用のMOBI、AZW3形式で保存できます。\n「HWPX writer」より上に置くと、電子書籍のPDF作成もCalibreが担当します(長い本では大幅に速くなります)。", "engine.libreoffice.description": "古い形式や一般的でない形式(DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visioなど)を読み込みます。\nDOC、XLS、PPT、ODT、ODP、OpenDocumentテンプレート、EMF形式での保存にも対応します。", - "engine.image-ocr.description": "スキャンしたPDFや画像からテキストを抽出し、編集可能な文書(Word、テキスト、HWPX)に変換します。", "profile.images": "画像", "profile.pdf": "PDF", "profile.office": "文書", @@ -538,6 +539,7 @@ "presets.pdf_ghostscript_hint": "さらに小さくできることがありますが、ファイル全体を書き換えます。\n最も小さい結果を保持します。", "presets.ocr": "文字を認識する(OCR)", "presets.ocr_hint": "スキャンや画像の文字を検索・編集できるようにします。Tesseract が必要です(「設定」→「変換エンジン」)。\nすでにテキストがあるページはそのままです。", + "presets.ocr_hint_macos": "スキャンや画像の文字を、macOS 標準の文字認識(Apple Vision)で検索・編集できるようにします。読めない言語には Tesseract が必要です(「設定」→「変換エンジン」)。\nすでにテキストがあるページはそのままです。", "presets.ocr_language": "テキストの言語", "presets.ocr_language_size": "{0} をダウンロード", "presets.pdf_keep_metadata": "ページと画像のメタデータ(XMP)を保持", @@ -551,14 +553,6 @@ "presets.leave_out_pictures": "画像を含めない(テキストのみ)", "presets.leave_out_pictures_hint": "オフ:画像をMarkdownファイルと同じ場所の「name_files」フォルダーに保存し、Markdownファイルからリンクします。\nオン:テキストだけを保存し、フォルダーは作成しません。", "presets.target_folder": "フォルダー(アーカイブを展開)", - "presets.group.ocr": "光学式文字認識 (OCR)", - "presets.ocr_enabled": "スキャンや画像内のテキストを認識", - "presets.ocr_hint": "スキャンしたページや画像から編集可能なテキストを抽出し、Word、テキスト、ドキュメントに変換します。", - "presets.ocr_language": "認識言語", - "presets.ocr_language_hint": "文字や単語を正確に認識するために使用する言語。", - "presets.ocr_language.auto": "自動 / システムのデフォルト", - "presets.ocr_engine_required": "Tesseractが必要です。設定 → エンジンからインストールしてください", - "presets.ocr_disabled_linux": "Linuxには組み込みOCRがありません。有効にするには設定 → エンジンでTesseractをインストールしてください。", "presets.group.media": "動画・音声", "presets.media_quality": "品質", "presets.media_quality_hint": "変換先が元のコーデックに対応している場合(iPhoneのMOV → MP4など)、動画を再エンコードせずに形式だけを変えます。\n画質を保ち、HDRも維持したまま数秒で完了します。\n「小さめ」はファイルサイズを減らすため、常に再エンコードします。", @@ -696,11 +690,6 @@ "engines.guidance.linux.ghostscript": "パッケージマネージャーでインストール(Ubuntu/Debian:sudo apt install ghostscript)してから、「再確認」を選んでください。", "engines.guidance.linux.tesseract": "パッケージマネージャーでインストール(Ubuntu/Debian:sudo apt install tesseract-ocr)してから、「再確認」を選んでください。", "engines.guidance.windows": "このPC向けの{0}はFileeからダウンロードできません。\n「手元のプログラムを使う…」を選び、インストール済みのものを指定してください。", - "engines.package.tesseract.name": "Tesseract(多言語テキスト認識)", - "engines.package.tesseract.description": "デーヴァナーガリー(ネパール語/ヒンディー語)、アラビア語、希少言語を含む100以上の言語のテキストを認識します。\nmacOSではApple Visionが英語、CJK、ヨーロッパ言語をネイティブにサポートしています。", - "engines.guidance.macos.tesseract": "HomebrewでTesseractをインストール(brew install tesseract tesseract-lang)してから「再確認」を選択してください。\nまたは「手元のプログラムを使う…」を選択してbinフォルダーを指定してください。", - "engines.guidance.linux.tesseract": "パッケージマネージャーでTesseractをインストール(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)してから「再確認」を選択してください。\nまたは「手元のプログラムを使う…」を選択してプログラムフォルダーを指定してください。", - "engines.guidance.windows.tesseract": "UB-Mannheim(github.com/UB-Mannheim/tesseract)からTesseractをインストールし、「再確認」を選択するかインストールフォルダーを指定してください。", "engines.use_own_app_folder": "{0}.appを含むフォルダーを選択してください", "engines.progress_detail": "{0}/{1} · {2}/秒 · {3}", "engines.eta.estimating": "残り時間を計算中…", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 5143bbe5e..1957ab72c 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -86,6 +86,7 @@ "error.cannot_turn": "회전·반전은 이미지에만 할 수 있어요", "error.no_transparency": "이 형식에는 투명한 부분이 없어서 잘라낼 것이 없어요", "error.ocr_language_download": "OCR 언어 데이터를 내려받지 못했어요", + "error.ocr_needs_tesseract": "Apple Vision은 이 언어를 읽지 못해요. 설정 → 변환 엔진에서 Tesseract를 설치해 주세요", "error.too_many_frames": "프레임이 너무 많아요. 'N번째 프레임마다' 값을 늘려 주세요", "error.too_many_pictures": "한 동영상에 넣기엔 사진이 너무 많아요. 여러 번에 나눠 주세요", "error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요", @@ -154,9 +155,9 @@ "engine.pandoc.description": "마크다운·HTML ↔ ODT·RTF, DOCX → 마크다운\nreStructuredText·LaTeX·Org·AsciiDoc·Typst·MediaWiki·DocBook·Jupyter 등 ↔ 마크다운·HTML·DOCX·ODT\n마크다운·Org → PPTX 슬라이드", "engine.ghostscript.description": "EPS, 포스트스크립트, 예전 일러스트레이터 파일을 PDF와 이미지로, PDF를 EPS·PS로.", "engine.tesseract.description": "스캔본과 사진의 글자를 인식해요(OCR): 검색 가능한 PDF, DOCX, TXT.", + "engine.vision.description": "macOS에 들어 있는 문자 인식으로 스캔본과 사진의 글자를 인식해요(OCR): 검색 가능한 PDF, DOCX, TXT. 따로 내려받을 게 없어요.", "engine.calibre.description": "드문 전자책 형식(LIT, LRF, CHM, PDB, RB…)과 킨들용 출력(MOBI, AZW3).\nHWPX writer보다 위에 두면 전자책 PDF도 만들어요(긴 책은 훨씬 빨라요).", "engine.libreoffice.description": "DOC, PPT, Pages, Keynote, Numbers, 워드퍼펙트, ClarisWorks, Publisher, Visio 같은 오래되었거나 드문 형식을 읽어요.\nDOC·XLS·PPT·ODT·ODP, 오픈도큐먼트 서식 파일, EMF로 저장도 해요.", - "engine.image-ocr.description": "스캔된 PDF 및 이미지에서 텍스트를 추출하여 편집 가능한 문서(Word, 텍스트, HWPX)로 변환합니다.", "profile.images": "이미지", "profile.pdf": "PDF", @@ -553,6 +554,7 @@ "presets.pdf_ghostscript_hint": "더 작아질 때가 많지만 파일 전체를 다시 써요.\n가장 작은 결과를 남겨요.", "presets.ocr": "텍스트 인식 (OCR)", "presets.ocr_hint": "스캔본과 사진의 글자를 검색하고 편집할 수 있게 돼요. Tesseract가 필요해요(설정 → 변환 엔진).\n이미 텍스트가 있는 페이지는 그대로 둬요.", + "presets.ocr_hint_macos": "스캔본과 사진의 글자를 macOS에 들어 있는 문자 인식(Apple Vision)으로 검색하고 편집할 수 있게 돼요. Apple Vision이 못 읽는 언어는 Tesseract가 필요해요(설정 → 변환 엔진).\n이미 텍스트가 있는 페이지는 그대로 둬요.", "presets.ocr_language": "텍스트 언어", "presets.ocr_language_size": "{0} 내려받기", "presets.pdf_keep_metadata": "페이지·이미지 메타데이터(XMP) 유지", @@ -566,14 +568,6 @@ "presets.leave_out_pictures": "그림 빼기 (텍스트만)", "presets.leave_out_pictures_hint": "끄면: 그림을 마크다운 파일 옆 \"이름_files\" 폴더에 저장하고 파일에서 연결해요.\n켜면: 폴더 없이 텍스트만 남겨요.", "presets.target_folder": "폴더 (압축 풀기)", - "presets.group.ocr": "광학 문자 인식 (OCR)", - "presets.ocr_enabled": "스캔 및 이미지에서 텍스트 인식", - "presets.ocr_hint": "스캔된 페이지와 이미지에서 편집 가능한 텍스트를 추출하여 Word, 텍스트 및 문서로 변환합니다.", - "presets.ocr_language": "인식 언어", - "presets.ocr_language_hint": "문자와 단어를 정확하게 인식하는 데 사용되는 언어입니다.", - "presets.ocr_language.auto": "자동 / 시스템 기본값", - "presets.ocr_engine_required": "Tesseract가 필요합니다. 설정 → 엔진에서 설치해 주세요", - "presets.ocr_disabled_linux": "Linux에는 내장 OCR이 없습니다. 활성화하려면 설정 → 엔진에서 Tesseract를 설치하세요.", "presets.group.media": "동영상·오디오", "presets.media_quality": "품질", "presets.media_quality_hint": "새 형식에 코덱을 그대로 담을 수 있으면(아이폰 MOV → MP4 등) 다시 인코딩하지 않아요.\n화질과 HDR이 그대로고 몇 초면 끝나요.\n'작게'는 용량을 줄이려고 항상 다시 인코딩해요.", @@ -713,11 +707,6 @@ "engines.guidance.linux.ghostscript": "패키지 관리자로 설치(Ubuntu/Debian: sudo apt install ghostscript)한 뒤 “다시 확인”을 누르세요.", "engines.guidance.linux.tesseract": "패키지 관리자로 설치(Ubuntu/Debian: sudo apt install tesseract-ocr)한 뒤 “다시 확인”을 누르세요.", "engines.guidance.windows": "이 PC용 {0} 다운로드는 없어요.\n“내 프로그램 쓰기…”로 설치된 프로그램을 선택해 주세요.", - "engines.package.tesseract.name": "Tesseract (다국어 텍스트 인식)", - "engines.package.tesseract.description": "데바나가리(네팔어/힌디어), 아랍어 및 희귀 문자를 포함한 100개 이상의 언어로 텍스트를 인식합니다.\nmacOS에서는 Apple Vision이 내장되어 영어, CJK 및 유럽어는 추가 설치 없이 동작합니다.", - "engines.guidance.macos.tesseract": "Homebrew로 Tesseract를 설치(brew install tesseract tesseract-lang)한 뒤 “다시 확인”을 선택하세요.\n또는 “내 프로그램 쓰기…”을 선택하고 bin 폴더를 지정하세요.", - "engines.guidance.linux.tesseract": "패키지 관리자로 Tesseract를 설치(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all)한 뒤 “다시 확인”을 선택하세요.\n또는 “내 프로그램 쓰기…”을 선택하고 프로그램 폴더를 지정하세요.", - "engines.guidance.windows.tesseract": "UB-Mannheim(github.com/UB-Mannheim/tesseract)에서 Tesseract를 설치한 뒤 “다시 확인”을 선택하거나 설치 폴더를 지정하세요.", "engines.use_own_app_folder": "{0}.app이 들어 있는 폴더 선택", "engines.progress_detail": "{0} / {1} · {2}/s · {3}", "engines.eta.estimating": "남은 시간 계산 중…", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index e8ce66a83..1f00a870a 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -79,6 +79,7 @@ "error.no_route": "कुनै पनि इन्जिनले यो रूपान्तरण गर्न सक्दैन", "error.cannot_turn": "घुमाउने र उल्ट्याउने कार्य केवल छविहरूमा मात्र काम गर्छ", "error.ocr_language_download": "OCR भाषा डाउनलोड गर्न सकिएन", + "error.ocr_needs_tesseract": "Apple Vision ले यो भाषा पढ्न सक्दैन। सेटिङहरू → इन्जिनहरूमा Tesseract स्थापना गर्नुहोस्", "error.no_transparency": "यो ढाँचामा पारदर्शिता हुँदैन, त्यसैले काट्नुपर्ने केही छैन", "error.too_many_frames": "छविका रूपमा सुरक्षित गर्न धेरै फ्रेमहरू छन्, \"प्रत्येक N-औं फ्रेम\" बढाउनुहोस्", "error.too_many_pictures": "एउटा भिडियोका लागि धेरै तस्बिरहरू छन्, तिनलाई केही भागमा बाँड्नुहोस्", @@ -147,9 +148,9 @@ "engine.pandoc.description": "Markdown र HTML ↔ ODT र RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter र थप ↔ Markdown, HTML, DOCX र ODT\nMarkdown र Org → PPTX स्लाइडहरू", "engine.ghostscript.description": "EPS, PostScript र पुराना Illustrator फाइलहरूलाई PDF र छविहरूमा; PDF लाई EPS र PS मा।", "engine.tesseract.description": "स्क्यान र तस्बिरको पाठ पहिचान गर्छ (OCR): खोज्न मिल्ने PDF, DOCX, TXT।", + "engine.vision.description": "macOS को आफ्नै पाठ पहिचानले स्क्यान र तस्बिरको पाठ पहिचान गर्छ (OCR): खोज्न मिल्ने PDF, DOCX, TXT। केही डाउनलोड गर्नु पर्दैन।", "engine.calibre.description": "दुर्लभ इ-पुस्तक ढाँचाहरू (LIT, LRF, CHM, PDB, RB…) र Kindle आउटपुट (MOBI, AZW3)।\nHWPX writer भन्दा माथि राख्दा, यसले इ-पुस्तकका PDF पनि बनाउँछ (लामो पुस्तकका लागि धेरै छिटो)।", "engine.libreoffice.description": "पुराना र कम प्रचलित ढाँचाहरू पढ्छ: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nसाथै DOC, XLS, PPT, ODT, ODP, OpenDocument टेम्प्लेट र EMF को रूपमा सुरक्षित गर्दछ।", - "engine.image-ocr.description": "स्क्यान गरिएका PDF र तस्बिरहरूबाट पाठ निकालेर सम्पादन योग्य कागजात (Word, पाठ, HWPX) बनाउँछ।", "profile.images": "छविहरू", "profile.pdf": "PDF", "profile.office": "कार्यालय कागजातहरू", @@ -538,6 +539,7 @@ "presets.pdf_ghostscript_hint": "प्रायः सानो हुन्छ, तर यसले सम्पूर्ण फाइल पुनः लेख्छ।\nसबैभन्दा सानो नतिजा राखिन्छ।", "presets.ocr": "पाठ पहिचान (OCR)", "presets.ocr_hint": "स्क्यान र तस्बिरको पाठ खोज्न र सम्पादन गर्न मिल्ने हुन्छ। Tesseract चाहिन्छ (सेटिङहरू → इन्जिनहरू)।\nपहिले नै पाठ भएका पृष्ठ जस्ताको तस्तै रहन्छन्।", + "presets.ocr_hint_macos": "macOS को आफ्नै पाठ पहिचान (Apple Vision) ले स्क्यान र तस्बिरको पाठ खोज्न र सम्पादन गर्न मिल्ने हुन्छ। यसले पढ्न नसक्ने भाषाका लागि Tesseract चाहिन्छ (सेटिङहरू → इन्जिनहरू)।\nपहिले नै पाठ भएका पृष्ठ जस्ताको तस्तै रहन्छन्।", "presets.ocr_language": "पाठको भाषा", "presets.ocr_language_size": "{0} डाउनलोड", "presets.pdf_keep_metadata": "पृष्ठ र तस्बिरको मेटाडाटा (XMP) राख्नुहोस्", @@ -551,14 +553,6 @@ "presets.leave_out_pictures": "तस्बिरहरू छोड्नुहोस् (पाठ मात्र)", "presets.leave_out_pictures_hint": "बन्द: तस्बिरहरू Markdown फाइलको छेउमा \"name_files\" फोल्डरमा सुरक्षित हुन्छन् र त्यहीँबाट लिङ्क हुन्छन्।\nचालु: केवल पाठ मात्र, कुनै फोल्डर छैन।", "presets.target_folder": "फोल्डर (अभिलेख निकाल्न)", - "presets.group.ocr": "अप्टिकल क्यारेक्टर रिकग्निसन (OCR)", - "presets.ocr_enabled": "स्क्यान र तस्बिरहरूमा पाठ चिन्नुहोस्", - "presets.ocr_hint": "स्क्यान गरिएका पृष्ठ र तस्बिरहरूबाट सम्पादन योग्य पाठ निकालेर Word, पाठ र कागजातमा राख्छ।", - "presets.ocr_language": "पहिचान भाषा", - "presets.ocr_language_hint": "अक्षर र शब्दहरू सही रूपमा चिन्नका लागि प्रयोग हुने भाषा।", - "presets.ocr_language.auto": "स्वचालित / प्रणाली पूर्वनिर्धारित", - "presets.ocr_engine_required": "Tesseract आवश्यक छ। यसलाई सेटिङहरू → इन्जिनहरूमा स्थापना गर्नुहोस्", - "presets.ocr_disabled_linux": "Linux मा अन्तर्निहित OCR छैन। सक्षम गर्न सेटिङहरू → इन्जिनहरूमा Tesseract स्थापना गर्नुहोस्।", "presets.group.media": "भिडियो र अडियो", "presets.media_quality": "गुणस्तर", "presets.media_quality_hint": "जब नयाँ ढाँचाले कोडेकहरूलाई जस्ताको तस्तै राख्न सक्छ (iPhone MOV → MP4), भिडियोलाई केवल पुन: र्‍याप गरिन्छ।\nकुनै गुणस्तर घट्दैन, HDR रहन्छ, सेकेन्डमै सकिन्छ।\nसानोले फाइल घटाउन सधैं पुन: इन्कोड गर्दछ।", @@ -696,11 +690,6 @@ "engines.guidance.linux.ghostscript": "यसलाई प्याकेज प्रबन्धकबाट स्थापना गर्नुहोस् (sudo apt install ghostscript), त्यसपछि \"फेरि जाँच गर्नुहोस्\" रोज्नुहोस्।", "engines.guidance.linux.tesseract": "यसलाई प्याकेज प्रबन्धकबाट स्थापना गर्नुहोस् (sudo apt install tesseract-ocr), त्यसपछि \"फेरि जाँच गर्नुहोस्\" रोज्नुहोस्।", "engines.guidance.windows": "यस कम्प्युटरका लागि Filee सँग {0} को डाउनलोड छैन।\n“मेरो प्रतिलिपि प्रयोग गर्नुहोस्…” रोज्नुहोस् र स्थापना गरिएको प्रतिलिपि चयन गर्नुहोस्।", - "engines.package.tesseract.name": "Tesseract (बहुभाषिक पाठ पहिचान)", - "engines.package.tesseract.description": "देवनागरी (नेपाली/हिन्दी), अरबी र दुर्लभ लिपिहरू सहित १०० भन्दा बढी भाषामा पाठ चिन्छ।\nmacOS मा, Apple Vision ले यो बिना नै अंग्रेजी, CJK र युरोपेली भाषाहरूका लागि मौलिक रूपमा काम गर्छ।", - "engines.guidance.macos.tesseract": "Homebrew बाट Tesseract स्थापना गर्नुहोस् (brew install tesseract tesseract-lang), त्यसपछि “फेरि जाँच गर्नुहोस्” रोज्नुहोस्।\nवा “मेरो प्रतिलिपि प्रयोग गर्नुहोस्…” रोजेर यसको bin फोल्डर चयन गर्नुहोस्।", - "engines.guidance.linux.tesseract": "आफ्नो प्याकेज म्यानेजरबाट Tesseract स्थापना गर्नुहोस् (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), त्यसपछि “फेरि जाँच गर्नुहोस्” रोज्नुहोस्।\nवा “मेरो प्रतिलिपि प्रयोग गर्नुहोस्…” रोजेर यसको प्रोग्राम फोल्डर चयन गर्नुहोस्।", - "engines.guidance.windows.tesseract": "UB-Mannheim (github.com/UB-Mannheim/tesseract) बाट Tesseract स्थापना गर्नुहोस्, त्यसपछि “फेरि जाँच गर्नुहोस्” रोज्नुहोस् वा यसको स्थापना फोल्डर चयन गर्नुहोस्।", "engines.use_own_app_folder": "{0}.app समावेश भएको फोल्डर रोज्नुहोस्", "engines.progress_detail": "{1} मध्ये {0} · {2}/s · {3}", "engines.eta.estimating": "अनुमान गरिँदैछ…", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 7d9c28953..1d7ba4f4d 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Obracanie i odbicie działają tylko na obrazach", "error.no_transparency": "Ten format nie ma przezroczystości, więc nie ma czego przycinać", "error.ocr_language_download": "Nie udało się pobrać języka OCR", + "error.ocr_needs_tesseract": "Apple Vision nie czyta tego języka. Zainstaluj Tesseract w Ustawienia → Silniki", "error.too_many_frames": "Za dużo klatek do zapisania jako obrazy, zwiększ „Co N-ta klatka”", "error.too_many_pictures": "Za dużo zdjęć na jedno wideo, podziel je na kilka", "error.not_enough_space": "Za mało wolnego miejsca na dysku na obrazy", @@ -154,9 +155,9 @@ "engine.pandoc.description": "Markdown i HTML ↔ ODT i RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter i inne ↔ Markdown, HTML, DOCX i ODT\nMarkdown i Org → slajdy PPTX", "engine.ghostscript.description": "EPS, PostScript i starsze pliki Illustratora do PDF i obrazów; PDF do EPS i PS.", "engine.tesseract.description": "Rozpoznaje tekst skanów i obrazów (OCR): przeszukiwalny PDF, DOCX, TXT.", + "engine.vision.description": "Rozpoznaje tekst skanów i obrazów wbudowanym rozpoznawaniem tekstu macOS (OCR): przeszukiwalny PDF, DOCX, TXT. Nic nie trzeba pobierać.", "engine.calibre.description": "Rzadkie formaty e-booków (LIT, LRF, CHM, PDB, RB…) i zapis dla Kindle (MOBI, AZW3).\nUmieszczony nad HWPX writer tworzy też PDF-y e-booków (znacznie szybciej przy długich książkach).", "engine.libreoffice.description": "Odczytuje starsze i rzadsze formaty: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nZapisuje też jako DOC, XLS, PPT, ODT, ODP, szablony OpenDocument i EMF.", - "engine.image-ocr.description": "Wyodrębnia tekst ze skanowanych plików PDF i obrazów do formatu edytowalnych dokumentów (Word, tekst, HWPX).", "profile.images": "Obrazy", "profile.pdf": "PDF", @@ -553,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Często daje mniejszy plik, ale zapisuje cały plik od nowa.\nZostaje najmniejszy wynik.", "presets.ocr": "Rozpoznaj tekst (OCR)", "presets.ocr_hint": "Tekst skanów i obrazów da się przeszukiwać i edytować. Wymaga Tesseract (Ustawienia → Silniki).\nStrony, które mają już tekst, pozostają bez zmian.", + "presets.ocr_hint_macos": "Tekst skanów i obrazów da się przeszukiwać i edytować dzięki wbudowanemu rozpoznawaniu tekstu macOS (Apple Vision). Języki, których nie czyta, wymagają Tesseract (Ustawienia → Silniki).\nStrony, które mają już tekst, pozostają bez zmian.", "presets.ocr_language": "Język tekstu", "presets.ocr_language_size": "Pobieranie {0}", "presets.pdf_keep_metadata": "Zachowaj metadane stron i obrazów (XMP)", @@ -566,14 +568,6 @@ "presets.leave_out_pictures": "Pomiń obrazy (tylko tekst)", "presets.leave_out_pictures_hint": "Wyłączone: obrazy trafiają do folderu „nazwa_files” obok pliku Markdown, który do nich linkuje.\nWłączone: tylko tekst, bez folderu.", "presets.target_folder": "Folder (rozpakuj archiwa)", - "presets.group.ocr": "Optyczne rozpoznawanie znaków (OCR)", - "presets.ocr_enabled": "Rozpoznaj tekst w skanach i obrazach", - "presets.ocr_hint": "Wyodrębnia edytowalny tekst ze skanowanych stron i obrazów do formatu Word, tekstu i dokumentów.", - "presets.ocr_language": "Język rozpoznawania", - "presets.ocr_language_hint": "Język używany do dokładnego rozpoznawania znaków i słów.", - "presets.ocr_language.auto": "Automatycznie / Domyślny systemu", - "presets.ocr_engine_required": "Wymaga Tesseract. Zainstaluj w Ustawienia → Silniki", - "presets.ocr_disabled_linux": "System Linux nie ma wbudowanego OCR. Zainstaluj Tesseract w Ustawienia → Silniki, aby włączyć.", "presets.group.media": "Wideo i audio", "presets.media_quality": "Jakość", "presets.media_quality_hint": "Gdy nowy format może przyjąć kodeki bez zmian (MOV z iPhone’a → MP4), wideo jest tylko przepakowywane.\nBez utraty jakości, z zachowaniem HDR, w kilka sekund.\nMała zawsze koduje ponownie, by zmniejszyć plik.", @@ -713,11 +707,6 @@ "engines.guidance.linux.ghostscript": "Zainstaluj menedżerem pakietów (Ubuntu/Debian: sudo apt install ghostscript), potem wybierz „Sprawdź ponownie”.", "engines.guidance.linux.tesseract": "Zainstaluj menedżerem pakietów (Ubuntu/Debian: sudo apt install tesseract-ocr), potem wybierz „Sprawdź ponownie”.", "engines.guidance.windows": "Filee nie ma do pobrania {0} dla tego komputera.\nWybierz „Użyj mojej kopii…” i wskaż zainstalowaną kopię.", - "engines.package.tesseract.name": "Tesseract (wielojęzyczne rozpoznawanie tekstu)", - "engines.package.tesseract.description": "Rozpoznaje tekst w ponad 100 językach, w tym dewanagari (nepalski/hindi), arabskim i rzadkich pismach.\nNa systemie macOS Apple Vision działa natywnie dla języka angielskiego, CJK i języków europejskich bez tego pakietu.", - "engines.guidance.macos.tesseract": "Zainstaluj Tesseract za pomocą Homebrew (brew install tesseract tesseract-lang), a następnie wybierz „Sprawdź ponownie”.\nLub wybierz „Użyj mojej kopii…” i wskaż folder bin.", - "engines.guidance.linux.tesseract": "Zainstaluj Tesseract za pomocą menedżera pakietów (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), a następnie wybierz „Sprawdź ponownie”.\nLub wybierz „Użyj mojej kopii…” i wskaż folder programu.", - "engines.guidance.windows.tesseract": "Zainstaluj Tesseract z UB-Mannheim (github.com/UB-Mannheim/tesseract), a następnie wybierz „Sprawdź ponownie” lub wskaż folder instalacji.", "engines.use_own_app_folder": "Wybierz folder zawierający {0}.app", "engines.progress_detail": "{0} z {1} · {2}/s · {3}", "engines.eta.estimating": "szacowanie pozostałego czasu…", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 5942b64e2..b058e3339 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Girar e espelhar só funcionam em imagens", "error.no_transparency": "Este formato não tem transparência, então não há nada para cortar", "error.ocr_language_download": "Não foi possível baixar o idioma do OCR", + "error.ocr_needs_tesseract": "O Apple Vision não lê este idioma. Instale o Tesseract em Configurações → Motores", "error.too_many_frames": "Quadros demais para salvar como imagens. Aumente “A cada N quadros”", "error.too_many_pictures": "Imagens demais para um vídeo; divida-as em vários", "error.not_enough_space": "Não há espaço livre suficiente em disco para as imagens", @@ -154,9 +155,9 @@ "engine.pandoc.description": "Markdown e HTML ↔ ODT e RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter e mais ↔ Markdown, HTML, DOCX e ODT\nMarkdown e Org → slides PPTX", "engine.ghostscript.description": "EPS, PostScript e arquivos antigos do Illustrator para PDF e imagens; PDF para EPS e PS.", "engine.tesseract.description": "Reconhece o texto de digitalizações e imagens (OCR): PDF pesquisável, DOCX, TXT.", + "engine.vision.description": "Reconhece o texto de digitalizações e imagens com o reconhecimento de texto do próprio macOS (OCR): PDF pesquisável, DOCX, TXT. Nada para baixar.", "engine.calibre.description": "Formatos raros de e-book (LIT, LRF, CHM, PDB, RB…) e saída Kindle (MOBI, AZW3).\nAcima do gravador de HWPX, também gera os PDFs de e-books (bem mais rápido em livros longos).", "engine.libreoffice.description": "Lê formatos antigos e menos comuns: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nTambém salva como DOC, XLS, PPT, ODT, ODP, modelos OpenDocument e EMF.", - "engine.image-ocr.description": "Extrai texto de PDFs digitalizados e imagens para documentos editáveis (Word, texto, HWPX).", "profile.images": "Imagens", "profile.pdf": "PDF", @@ -553,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Muitas vezes menor, mas reescreve o arquivo inteiro.\nO menor resultado é mantido.", "presets.ocr": "Reconhecer texto (OCR)", "presets.ocr_hint": "Digitalizações e imagens ganham texto que pode ser pesquisado e editado. Requer o Tesseract (Configurações → Motores).\nPáginas que já têm texto ficam como estão.", + "presets.ocr_hint_macos": "Digitalizações e imagens ganham texto que pode ser pesquisado e editado, com o reconhecimento de texto do próprio macOS (Apple Vision). Idiomas que ele não lê precisam do Tesseract (Configurações → Motores).\nPáginas que já têm texto ficam como estão.", "presets.ocr_language": "Idioma do texto", "presets.ocr_language_size": "Download de {0}", "presets.pdf_keep_metadata": "Manter metadados de página e imagem (XMP)", @@ -566,14 +568,6 @@ "presets.leave_out_pictures": "Deixar as imagens de fora (só texto)", "presets.leave_out_pictures_hint": "Desligado: as imagens são salvas numa pasta “nome_files” ao lado do arquivo Markdown e referenciadas nele.\nLigado: só o texto, sem pasta.", "presets.target_folder": "Pasta (extrair arquivos compactados)", - "presets.group.ocr": "Reconhecimento Óptico de Caracteres (OCR)", - "presets.ocr_enabled": "Reconhecer texto em digitalizações e imagens", - "presets.ocr_hint": "Extrai texto editável de páginas digitalizadas e imagens para Word, texto e documentos.", - "presets.ocr_language": "Idioma de reconhecimento", - "presets.ocr_language_hint": "Idioma usado para reconhecer caracteres e palavras com precisão.", - "presets.ocr_language.auto": "Automático / Padrão do sistema", - "presets.ocr_engine_required": "Requer Tesseract. Instale em Configurações → Motores", - "presets.ocr_disabled_linux": "O Linux não possui OCR integrado. Instale o Tesseract em Configurações → Motores para ativar.", "presets.group.media": "Vídeo e áudio", "presets.media_quality": "Qualidade", "presets.media_quality_hint": "Quando o novo formato pode guardar os codecs como estão (MOV do iPhone → MP4), o vídeo só é reempacotado.\nSem perda de qualidade, HDR mantido, pronto em segundos.\nPequeno sempre recodifica para deixar o arquivo menor.", @@ -713,11 +707,6 @@ "engines.guidance.linux.ghostscript": "Instale com o gerenciador de pacotes (Ubuntu/Debian: sudo apt install ghostscript) e escolha “Verificar de novo”.", "engines.guidance.linux.tesseract": "Instale com o gerenciador de pacotes (Ubuntu/Debian: sudo apt install tesseract-ocr) e escolha “Verificar de novo”.", "engines.guidance.windows": "O Filee não tem download de {0} para este PC.\nEscolha “Usar minha cópia…” e selecione uma cópia instalada.", - "engines.package.tesseract.name": "Tesseract (reconhecimento de texto multilíngue)", - "engines.package.tesseract.description": "Reconhece texto em mais de 100 idiomas, incluindo devanágari (nepalês/híndi), árabe e alfabetos raros.\nNo macOS, o Apple Vision funciona nativamente para inglês, CJK e idiomas europeus sem ele.", - "engines.guidance.macos.tesseract": "Instale o Tesseract com o Homebrew (brew install tesseract tesseract-lang) e selecione “Verificar novamente”.\nOu escolha “Usar minha cópia…” e selecione a pasta bin.", - "engines.guidance.linux.tesseract": "Instale o Tesseract com o gerenciador de pacotes (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all) e selecione “Verificar novamente”.\nOu escolha “Usar minha cópia…” e selecione a pasta do programa.", - "engines.guidance.windows.tesseract": "Instale o Tesseract do UB-Mannheim (github.com/UB-Mannheim/tesseract) e selecione “Verificar novamente” ou selecione sua pasta de instalação.", "engines.use_own_app_folder": "Escolha a pasta que contém {0}.app", "engines.progress_detail": "{0} de {1} · {2}/s · {3}", "engines.eta.estimating": "estimando o tempo restante…", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index dadddc846..040bb4a4d 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Поворот и отражение работают только с изображениями", "error.no_transparency": "В этом формате нет прозрачности, обрезать нечего", "error.ocr_language_download": "Не удалось скачать язык для OCR", + "error.ocr_needs_tesseract": "Apple Vision не читает этот язык. Установите Tesseract в разделе «Настройки → Движки»", "error.too_many_frames": "Слишком много кадров для сохранения картинками — увеличьте «Каждый N-й кадр»", "error.too_many_pictures": "Слишком много картинок для одного видео — разделите их на несколько", "error.not_enough_space": "Не хватает места на диске для картинок", @@ -154,9 +155,9 @@ "engine.pandoc.description": "Markdown и HTML ↔ ODT и RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter и другие ↔ Markdown, HTML, DOCX и ODT\nMarkdown и Org → слайды PPTX", "engine.ghostscript.description": "EPS, PostScript и старые файлы Illustrator — в PDF и картинки; PDF — в EPS и PS.", "engine.tesseract.description": "Распознаёт текст сканов и изображений (OCR): PDF с поиском, DOCX, TXT.", + "engine.vision.description": "Распознаёт текст сканов и изображений встроенным в macOS распознаванием текста (OCR): PDF с поиском, DOCX, TXT. Ничего скачивать не нужно.", "engine.calibre.description": "Редкие форматы электронных книг (LIT, LRF, CHM, PDB, RB…) и вывод для Kindle (MOBI, AZW3).\nЕсли стоит выше HWPX writer, он же делает PDF из электронных книг (для длинных книг намного быстрее).", "engine.libreoffice.description": "Читает старые и редкие форматы: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nА ещё сохраняет в DOC, XLS, PPT, ODT, ODP, шаблоны OpenDocument и EMF.", - "engine.image-ocr.description": "Извлекает текст из отсканированных PDF и изображений в редактируемые документы (Word, текст, HWPX).", "profile.images": "Изображения", "profile.pdf": "PDF", @@ -553,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Часто получается меньше, но он перезаписывает весь файл.\nОстаётся самый маленький результат.", "presets.ocr": "Распознать текст (OCR)", "presets.ocr_hint": "Текст сканов и изображений можно будет искать и редактировать. Нужен Tesseract (Настройки → Движки).\nСтраницы, где уже есть текст, не меняются.", + "presets.ocr_hint_macos": "Текст сканов и изображений можно будет искать и редактировать с помощью встроенного в macOS распознавания текста (Apple Vision). Для языков, которые оно не читает, нужен Tesseract (Настройки → Движки).\nСтраницы, где уже есть текст, не меняются.", "presets.ocr_language": "Язык текста", "presets.ocr_language_size": "Загрузка {0}", "presets.pdf_keep_metadata": "Сохранять метаданные страниц и картинок (XMP)", @@ -566,14 +568,6 @@ "presets.leave_out_pictures": "Без картинок (только текст)", "presets.leave_out_pictures_hint": "Выключено: картинки сохраняются в папку «имя_files» рядом с файлом Markdown и связаны с ним ссылками.\nВключено: только текст, без папки.", "presets.target_folder": "Папка (распаковка архивов)", - "presets.group.ocr": "Оптическое распознавание символов (OCR)", - "presets.ocr_enabled": "Распознавать текст в сканах и изображениях", - "presets.ocr_hint": "Извлекает редактируемый текст из отсканированных страниц и изображений в Word, текст и документы.", - "presets.ocr_language": "Язык распознавания", - "presets.ocr_language_hint": "Язык для точного распознавания символов и слов.", - "presets.ocr_language.auto": "Автоматически / По умолчанию", - "presets.ocr_engine_required": "Требуется Tesseract. Установите его в Настройки → Движки", - "presets.ocr_disabled_linux": "В Linux нет встроенного OCR. Установите Tesseract в Настройки → Движки, чтобы включить.", "presets.group.media": "Видео и аудио", "presets.media_quality": "Качество", "presets.media_quality_hint": "Если новый формат может принять кодеки как есть (MOV с iPhone → MP4), видео только переупаковывается.\nБез потери качества, HDR сохраняется, за секунды.\n«Компактное» всегда перекодирует, чтобы уменьшить файл.", @@ -713,11 +707,6 @@ "engines.guidance.linux.ghostscript": "Установите менеджером пакетов (Ubuntu/Debian: sudo apt install ghostscript), затем выберите «Проверить снова».", "engines.guidance.linux.tesseract": "Установите менеджером пакетов (Ubuntu/Debian: sudo apt install tesseract-ocr), затем выберите «Проверить снова».", "engines.guidance.windows": "Filee не может скачать {0} для этого компьютера.\nВыберите «Использовать свою копию…» и укажите установленную копию.", - "engines.package.tesseract.name": "Tesseract (многоязычное распознавание текста)", - "engines.package.tesseract.description": "Распознает текст на более чем 100 языках, включая деванагари (непальский/хинди), арабский и редкие письменности.\nВ macOS Apple Vision работает нативно для английского, CJK и европейских языков без него.", - "engines.guidance.macos.tesseract": "Установите Tesseract с помощью Homebrew (brew install tesseract tesseract-lang), затем выберите «Проверить снова».\nИли выберите «Использовать свою копию…» и укажите папку bin.", - "engines.guidance.linux.tesseract": "Установите Tesseract с помощью пакетного менеджера (Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all), затем выберите «Проверить снова».\nИли выберите «Использовать свою копию…» и укажите папку программы.", - "engines.guidance.windows.tesseract": "Установите Tesseract из UB-Mannheim (github.com/UB-Mannheim/tesseract), затем выберите «Проверить снова» или укажите папку установки.", "engines.use_own_app_folder": "Выберите папку, в которой лежит {0}.app", "engines.progress_detail": "{0} из {1} · {2}/с · {3}", "engines.eta.estimating": "оцениваем, сколько осталось…", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 013e31ff8..5b051af4a 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -79,6 +79,7 @@ "error.no_route": "ไม่มีเครื่องมือที่แปลงฟอร์แมตนี้ได้", "error.cannot_turn": "การหมุนและพลิกภาพใช้ได้กับไฟล์รูปภาพเท่านั้น", "error.ocr_language_download": "ดาวน์โหลดภาษาสำหรับ OCR ไม่ได้", + "error.ocr_needs_tesseract": "Apple Vision อ่านภาษานี้ไม่ได้ ติดตั้ง Tesseract ได้ในการตั้งค่า → เครื่องมือแปลงไฟล์", "error.no_transparency": "ฟอร์แมตนี้ไม่มีความโปร่งใส จึงไม่มีขอบให้ตัดออก", "error.too_many_frames": "มีจำนวนเฟรมมากเกินกว่าจะบันทึกเป็นรูปภาพได้ ลองเพิ่มค่า \"บันทึกทุกๆ N เฟรม\"", "error.too_many_pictures": "รูปภาพมากเกินไปสำหรับวิดีโอเดียว ลองแบ่งเป็นหลายวิดีโอ", @@ -147,6 +148,7 @@ "engine.pandoc.description": "Markdown และ HTML ↔ ODT และ RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter และอื่นๆ ↔ Markdown, HTML, DOCX และ ODT\nMarkdown และ Org → สไลด์ PPTX", "engine.ghostscript.description": "EPS, PostScript และไฟล์ Illustrator รุ่นเก่าเป็น PDF และรูปภาพ แปลง PDF เป็น EPS และ PS", "engine.tesseract.description": "อ่านข้อความจากภาพสแกนและรูปภาพ (OCR): PDF ที่ค้นหาได้, DOCX, TXT", + "engine.vision.description": "อ่านข้อความจากภาพสแกนและรูปภาพด้วยระบบรู้จำข้อความในตัวของ macOS (OCR): PDF ที่ค้นหาได้, DOCX, TXT ไม่ต้องดาวน์โหลดอะไรเพิ่ม", "engine.calibre.description": "ฟอร์แมตอีบุ๊กที่หาได้ยาก (LIT, LRF, CHM, PDB, RB…) และไฟล์สำหรับ Kindle (MOBI, AZW3)\nเมื่อวางไว้เหนือตัวเขียน HWPX จะทำหน้าที่สร้าง PDF ของอีบุ๊กด้วย (เร็วกว่ามากสำหรับหนังสือเล่มหนา)", "engine.libreoffice.description": "อ่านฟอร์แมตรุ่นเก่าและฟอร์แมตที่พบน้อย: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nและบันทึกเป็น DOC, XLS, PPT, ODT, ODP, แม่แบบ OpenDocument และ EMF", "profile.images": "รูปภาพ", @@ -537,6 +539,7 @@ "presets.pdf_ghostscript_hint": "มักจะได้ไฟล์ที่เล็กลง แต่อาจเขียนโครงสร้างไฟล์ใหม่ทั้งหมด\nระบบจะเลือกเก็บไฟล์ผลลัพธ์ที่ขนาดเล็กที่สุดไว้", "presets.ocr": "รู้จำข้อความ (OCR)", "presets.ocr_hint": "ข้อความในภาพสแกนและรูปภาพจะค้นหาและแก้ไขได้ ต้องใช้ Tesseract (การตั้งค่า → เครื่องมือแปลงไฟล์)\nหน้าที่มีข้อความอยู่แล้วจะคงไว้ตามเดิม", + "presets.ocr_hint_macos": "ข้อความในภาพสแกนและรูปภาพจะค้นหาและแก้ไขได้ด้วยระบบรู้จำข้อความในตัวของ macOS (Apple Vision) ภาษาที่อ่านไม่ได้ต้องใช้ Tesseract (การตั้งค่า → เครื่องมือแปลงไฟล์)\nหน้าที่มีข้อความอยู่แล้วจะคงไว้ตามเดิม", "presets.ocr_language": "ภาษาของข้อความ", "presets.ocr_language_size": "ดาวน์โหลด {0}", "presets.pdf_keep_metadata": "คงเมทาดาตาของหน้าและรูปภาพ (XMP)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index c164d79c8..03c641f5a 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -86,6 +86,7 @@ "error.cannot_turn": "只能旋转和翻转图片", "error.no_transparency": "此格式没有透明部分,无需裁剪", "error.ocr_language_download": "无法下载 OCR 语言数据", + "error.ocr_needs_tesseract": "Apple Vision 无法识别此语言。请在“设置 → 转换引擎”中安装 Tesseract", "error.too_many_frames": "帧数太多,请调大“每 N 帧”", "error.too_many_pictures": "图片太多,无法放进一个视频,请分成几次", "error.not_enough_space": "磁盘空间不足,无法保存这些图片", @@ -154,9 +155,9 @@ "engine.pandoc.description": "Markdown 和 HTML ↔ ODT、RTF,DOCX → Markdown\nreStructuredText、LaTeX、Org、AsciiDoc、Typst、MediaWiki、DocBook、Jupyter 等 ↔ Markdown、HTML、DOCX、ODT\nMarkdown 和 Org → PPTX 幻灯片", "engine.ghostscript.description": "EPS、PostScript 和旧版 Illustrator 文件转为 PDF 和图片;PDF 转为 EPS 和 PS。", "engine.tesseract.description": "识别扫描件和图片中的文字(OCR):可搜索的 PDF、DOCX、TXT。", + "engine.vision.description": "使用 macOS 自带的文字识别来识别扫描件和图片中的文字(OCR):可搜索的 PDF、DOCX、TXT。无需下载。", "engine.calibre.description": "少见的电子书格式(LIT、LRF、CHM、PDB、RB…)以及 Kindle 输出(MOBI、AZW3)。\n排在 HWPX writer 之上时,也由它把电子书转为 PDF(长书快得多)。", "engine.libreoffice.description": "读取较旧或少见的格式,如 DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visio。\n也能保存为 DOC、XLS、PPT、ODT、ODP、OpenDocument 模板或 EMF。", - "engine.image-ocr.description": "从扫描 PDF 和图片中提取文字并转换为可编辑文档(Word、文本、HWPX)。", "profile.images": "图片", "profile.pdf": "PDF", @@ -553,6 +554,7 @@ "presets.pdf_ghostscript_hint": "通常更小,但会重写整个文件。\n保留最小的结果。", "presets.ocr": "识别文字(OCR)", "presets.ocr_hint": "让扫描件和图片中的文字可以搜索和编辑。需要 Tesseract(设置 → 转换引擎)。\n已有文字的页面保持不变。", + "presets.ocr_hint_macos": "使用 macOS 自带的文字识别(Apple Vision),让扫描件和图片中的文字可以搜索和编辑。它无法识别的语言需要 Tesseract(设置 → 转换引擎)。\n已有文字的页面保持不变。", "presets.ocr_language": "文字的语言", "presets.ocr_language_size": "下载 {0}", "presets.pdf_keep_metadata": "保留页面和图片元数据 (XMP)", @@ -566,14 +568,6 @@ "presets.leave_out_pictures": "不保留图片(仅文本)", "presets.leave_out_pictures_hint": "关闭时,图片保存在 Markdown 文件旁的“名称_files”文件夹中,并从文件中链接。\n开启时,只保留文字,不生成文件夹。", "presets.target_folder": "文件夹(解压缩)", - "presets.group.ocr": "光学字符识别 (OCR)", - "presets.ocr_enabled": "识别扫描件和图像中的文本", - "presets.ocr_hint": "从扫描页面和图像中提取可编辑文本并转换为 Word、文本及文档。", - "presets.ocr_language": "识别语言", - "presets.ocr_language_hint": "用于准确识别字符和文字的语言。", - "presets.ocr_language.auto": "自动 / 系统默认", - "presets.ocr_engine_required": "需要 Tesseract。请在“设置 → 转换引擎”中安装", - "presets.ocr_disabled_linux": "Linux 没有内置 OCR。请在“设置 → 转换引擎”中安装 Tesseract 以启用。", "presets.group.media": "视频和音频", "presets.media_quality": "质量", "presets.media_quality_hint": "新格式能直接容纳原编码时(如 iPhone MOV → MP4),只重新封装。\n画质无损、保留 HDR,几秒即可完成。\n“小”总会重新编码以减小文件。", @@ -713,11 +707,6 @@ "engines.guidance.linux.ghostscript": "用软件包管理器安装(Ubuntu/Debian 下运行 sudo apt install ghostscript)后点“重新检查”。", "engines.guidance.linux.tesseract": "用软件包管理器安装(Ubuntu/Debian 下运行 sudo apt install tesseract-ocr)后点“重新检查”。", "engines.guidance.windows": "没有适用于这台电脑的 {0} 下载。\n点“使用我的程序…”选择已安装的程序。", - "engines.package.tesseract.name": "Tesseract(多语言文字识别)", - "engines.package.tesseract.description": "支持梵文(尼泊尔语/印地语)、阿拉伯语等 100 多种语言的文字识别。\n在 macOS 上,系统内置 Apple Vision 可直接识别英语、中日韩及主要欧洲语言。", - "engines.guidance.macos.tesseract": "使用 Homebrew 安装 Tesseract(brew install tesseract tesseract-lang),然后选择“重新检查”。\n或选择“使用我的副本…”并指定其 bin 文件夹。", - "engines.guidance.linux.tesseract": "使用包管理器安装 Tesseract(Ubuntu/Debian: sudo apt install tesseract-ocr tesseract-ocr-all),然后选择“重新检查”。\n或选择“使用我的副本…”并指定其程序文件夹。", - "engines.guidance.windows.tesseract": "从 UB-Mannheim (github.com/UB-Mannheim/tesseract) 安装 Tesseract,然后选择“重新检查”或指定其安装文件夹。", "engines.use_own_app_folder": "选择包含 {0}.app 的文件夹", "engines.progress_detail": "{0} / {1} · {2}/s · {3}", "engines.eta.estimating": "正在估算剩余时间…", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index e75a754f5..377203292 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -86,6 +86,7 @@ "error.cannot_turn": "只能旋轉和翻轉圖片", "error.no_transparency": "此格式沒有透明部分,無需裁切", "error.ocr_language_download": "無法下載 OCR 語言資料", + "error.ocr_needs_tesseract": "Apple Vision 無法辨識此語言。請在「設定 → 轉換引擎」中安裝 Tesseract", "error.too_many_frames": "影格數太多,請調大「每 N 個影格」", "error.too_many_pictures": "圖片太多,無法放進同一部影片,請分成幾次", "error.not_enough_space": "磁碟空間不足,無法儲存這些圖片", @@ -154,9 +155,9 @@ "engine.pandoc.description": "Markdown 和 HTML ↔ ODT、RTF,DOCX → Markdown\nreStructuredText、LaTeX、Org、AsciiDoc、Typst、MediaWiki、DocBook、Jupyter 等 ↔ Markdown、HTML、DOCX、ODT\nMarkdown 和 Org → PPTX 投影片", "engine.ghostscript.description": "EPS、PostScript 和舊版 Illustrator 檔案轉為 PDF 和圖片;PDF 轉為 EPS 和 PS。", "engine.tesseract.description": "辨識掃描檔和圖片中的文字(OCR):可搜尋的 PDF、DOCX、TXT。", + "engine.vision.description": "使用 macOS 內建的文字辨識來辨識掃描檔和圖片中的文字(OCR):可搜尋的 PDF、DOCX、TXT。不需要下載。", "engine.calibre.description": "少見的電子書格式(LIT、LRF、CHM、PDB、RB…)以及 Kindle 輸出(MOBI、AZW3)。\n排在 HWPX writer 之上時,也由它把電子書轉為 PDF(長書快得多)。", "engine.libreoffice.description": "讀取較舊或少見的格式,如 DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visio。\n也能儲存為 DOC、XLS、PPT、ODT、ODP、OpenDocument 範本或 EMF。", - "engine.image-ocr.description": "從掃描的 PDF 與圖片擷取文字,轉成可編輯的文件(Word、文字檔、HWPX)。", "profile.images": "圖片", "profile.pdf": "PDF", @@ -553,6 +554,7 @@ "presets.pdf_ghostscript_hint": "通常更小,但會重寫整個檔案。\n保留最小的結果。", "presets.ocr": "辨識文字(OCR)", "presets.ocr_hint": "讓掃描檔和圖片中的文字可以搜尋和編輯。需要 Tesseract(設定 → 轉換引擎)。\n已有文字的頁面保持不變。", + "presets.ocr_hint_macos": "使用 macOS 內建的文字辨識(Apple Vision),讓掃描檔和圖片中的文字可以搜尋和編輯。它無法辨識的語言需要 Tesseract(設定 → 轉換引擎)。\n已有文字的頁面保持不變。", "presets.ocr_language": "文字的語言", "presets.ocr_language_size": "下載 {0}", "presets.pdf_keep_metadata": "保留頁面和圖片中繼資料 (XMP)", @@ -566,14 +568,6 @@ "presets.leave_out_pictures": "不保留圖片(僅文字)", "presets.leave_out_pictures_hint": "關閉時,圖片儲存在 Markdown 檔案旁的「名稱_files」資料夾中,並從檔案中連結。\n開啟時,只保留文字,不產生資料夾。", "presets.target_folder": "資料夾(解壓縮)", - "presets.group.ocr": "光學字元辨識(OCR)", - "presets.ocr_enabled": "辨識掃描檔與圖片中的文字", - "presets.ocr_hint": "從掃描頁面與圖片擷取可編輯的文字,存成 Word、文字檔或文件。", - "presets.ocr_language": "辨識語言", - "presets.ocr_language_hint": "用於準確辨識文字與單字的語言。", - "presets.ocr_language.auto": "自動/系統預設", - "presets.ocr_engine_required": "需要 Tesseract,請在「設定 → 轉換引擎」中安裝", - "presets.ocr_disabled_linux": "Linux 沒有內建 OCR。請在「設定 → 轉換引擎」中安裝 Tesseract 以啟用。", "presets.group.media": "影片和音訊", "presets.media_quality": "品質", "presets.media_quality_hint": "新格式能直接容納原編碼時(如 iPhone MOV → MP4),只重新封裝。\n畫質無損、保留 HDR,幾秒即可完成。\n「小」總會重新編碼以減小檔案。", @@ -713,11 +707,6 @@ "engines.guidance.linux.ghostscript": "用套件管理員安裝(Ubuntu/Debian 下執行 sudo apt install ghostscript)後點「重新檢查」。", "engines.guidance.linux.tesseract": "用套件管理員安裝(Ubuntu/Debian 下執行 sudo apt install tesseract-ocr)後點「重新檢查」。", "engines.guidance.windows": "沒有適用於這台電腦的 {0} 下載。\n點「使用我的程式…」選擇已安裝的程式。", - "engines.package.tesseract.name": "Tesseract(多語言文字辨識)", - "engines.package.tesseract.description": "可辨識超過 100 種語言的文字,包括天城文(尼泊爾文/印地文)、阿拉伯文及少見文字。\n在 macOS 上,Apple Vision 原生支援英文、中日韓與歐洲語言,無需安裝它。", - "engines.guidance.macos.tesseract": "用 Homebrew 安裝 Tesseract(brew install tesseract tesseract-lang),然後點「重新檢查」。\n或點「使用我的程式…」並選擇其 bin 資料夾。", - "engines.guidance.linux.tesseract": "用套件管理員安裝 Tesseract(Ubuntu/Debian:sudo apt install tesseract-ocr tesseract-ocr-all),然後點「重新檢查」。\n或點「使用我的程式…」並選擇其程式資料夾。", - "engines.guidance.windows.tesseract": "從 UB-Mannheim(github.com/UB-Mannheim/tesseract)安裝 Tesseract,然後點「重新檢查」或選擇其安裝資料夾。", "engines.use_own_app_folder": "選擇包含 {0}.app 的資料夾", "engines.progress_detail": "{0} / {1} · {2}/s · {3}", "engines.eta.estimating": "正在估算剩餘時間…", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 0c9ff9832..bb6164d70 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -4,7 +4,6 @@ using Filee.App.ViewModels; using Filee.Core.Conversion; using Filee.Core.Localization; -using Filee.Core.Ocr; using Filee.Core.Platform; using Filee.Core.Settings; using Filee.Core.Web; @@ -49,8 +48,6 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore services.AddSingleton(); // Conversion engines - if (OperatingSystem.IsMacOS()) - OcrEngineRegistry.Register(new MacOsVisionOcrEngine()); services.AddSingleton(_ => new EngineEnvironment(dataDir)); services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(sp => new ConverterCatalog(sp.GetRequiredService>())); diff --git a/src/Filee.App/Services/EngineDownloadService.cs b/src/Filee.App/Services/EngineDownloadService.cs index 59ab2239d..2bd410562 100644 --- a/src/Filee.App/Services/EngineDownloadService.cs +++ b/src/Filee.App/Services/EngineDownloadService.cs @@ -122,7 +122,8 @@ public string? InstallGuidance || (Status == EnginePackageStatus.OwnCopy && FromSystem); public bool IsBusy => Status is EnginePackageStatus.Queued or EnginePackageStatus.Downloading or EnginePackageStatus.Unpacking; /// Also when a copy on the PC is used: Filee's download is the tested version and wins once installed. - public bool CanInstall => EngineDownloads.CanDownload(Package) && (Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed || (Status == EnginePackageStatus.OwnCopy && FromSystem)); + public bool CanInstall => EngineDownloads.CanDownload(Package) && (Status is EnginePackageStatus.NotInstalled or EnginePackageStatus.Failed + || (Status == EnginePackageStatus.OwnCopy && FromSystem)); public string StatusText => Status switch { @@ -282,8 +283,7 @@ public async Task InstallAsync(EnginePackageState state) { _oneAtATime.Release(); } - EngineEnvironment.ForgetSystemCopies(); - state.Status = StatusOf(state); + state.Status = EnginePackageStatus.Installed; _catalog.Refresh(); if (state.Package.ConverterIds.Contains("libreoffice")) WarmUpInBackground(TimeSpan.Zero); @@ -347,7 +347,7 @@ public bool Uninstall(EnginePackageState state) try { _installer.Uninstall(state.Package); - state.Status = StatusOf(state); + state.Status = EnginePackageStatus.NotInstalled; _catalog.Refresh(); return true; } diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 1fee4e9bf..8280cd90e 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -83,10 +83,14 @@ .. FormatRegistry.Known PageSizes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.page_size.{v}"])).ToList(); CompressionLevels = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.pdf_compression.{v}"])).ToList(); // Each language under its own name; one not downloaded yet shows its size (it downloads with the first file). + // On a Mac, Apple Vision reads the common languages without a download. + var vision = new VisionConverter(); + OcrHint = loc[vision.GetStatus().IsAvailable ? "presets.ocr_hint_macos" : "presets.ocr_hint"]; OcrLanguageChoices = [.. OcrLanguages.All .OrderBy(l => l.Code != OcrLanguages.Default).ThenBy(l => l.NativeName, StringComparer.CurrentCulture) .Select(l => new Choice(l.Code, l.NativeName, - OcrLanguages.IsInstalled(l.Code) ? null : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; + OcrLanguages.IsInstalled(l.Code) || vision.Reads(l.Code) ? null + : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; Locations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])).ToList(); Conflicts = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.conflict.{v}"])).ToList(); TiffCompressions = Enum.GetValues().Select(v => new Choice(v, v.ToString().ToUpperInvariant())).ToList(); @@ -191,6 +195,9 @@ .. FormatRegistry.Known /// Languages Tesseract reads (PdfOptions.OcrLanguage), English first. public IReadOnlyList> OcrLanguageChoices { get; } + /// What "Recognize text" needs: Tesseract, or on a Mac Apple Vision (Tesseract only for other languages). + public string OcrHint { get; } + /// PDF → PDF: don't compress, or the level (pictures at 72, 150 or 300 dpi). public IReadOnlyList> CompressionLevels { get; } public IReadOnlyList> Locations { get; } diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 310b42d97..d61cef22e 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -304,38 +304,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -357,10 +325,10 @@ - + - + diff --git a/src/Filee.Cli/Filee.Cli.csproj b/src/Filee.Cli/Filee.Cli.csproj index 1883ea6d1..af728be3a 100644 --- a/src/Filee.Cli/Filee.Cli.csproj +++ b/src/Filee.Cli/Filee.Cli.csproj @@ -14,7 +14,6 @@ - diff --git a/src/Filee.Core/Ocr/IOcrEngine.cs b/src/Filee.Core/Ocr/IOcrEngine.cs deleted file mode 100644 index 4f320abdd..000000000 --- a/src/Filee.Core/Ocr/IOcrEngine.cs +++ /dev/null @@ -1,30 +0,0 @@ -// The contract every OCR engine implements (Apple Vision, Tesseract): recognize text and word boxes in an image, -// so callers like PdfDocumentReader can place the result back onto the page without knowing which engine ran. - -namespace Filee.Core.Ocr; - -/// -/// A service that extracts text and bounding boxes from images. -/// -public interface IOcrEngine -{ - /// Unique engine identifier (e.g. "vision", "tesseract"). - string Id { get; } - - /// Display name (e.g. "Apple Vision", "Tesseract OCR"). - string DisplayName { get; } - - /// Whether this engine is available and ready on the current machine. - bool IsAvailable { get; } - - /// Supported languages (BCP-47 codes, or empty if generic/all). - IReadOnlyList SupportedLanguages { get; } - - /// - /// Recognizes text in an image file. - /// - /// Path to an image file (PNG, JPG, TIFF, etc.). - /// Optional language hint or code. - /// Cancellation token. - Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default); -} diff --git a/src/Filee.Core/Ocr/OcrEngineRegistry.cs b/src/Filee.Core/Ocr/OcrEngineRegistry.cs deleted file mode 100644 index 998a968b8..000000000 --- a/src/Filee.Core/Ocr/OcrEngineRegistry.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Where callers (PdfDocumentReader, ImageOcrConverter, the preset editor) find an OCR engine without depending on -// Filee.Platform.MacOS or Filee.Engines directly: Tesseract registers itself through EngineRegistry.CreateAll on -// every platform, Apple Vision is discovered lazily and by reflection since it lives in a macOS-only assembly. - -namespace Filee.Core.Ocr; - -/// -/// Registry of available OCR engines. -/// -public static class OcrEngineRegistry -{ - private static readonly List RegisteredEngines = []; - private static readonly Lock Sync = new(); - - public static void Register(IOcrEngine engine) - { - ArgumentNullException.ThrowIfNull(engine); - lock (Sync) - { - if (!RegisteredEngines.Any(e => e.Id.Equals(engine.Id, StringComparison.OrdinalIgnoreCase))) - { - RegisteredEngines.Add(engine); - } - } - } - - public static IReadOnlyList All - { - get - { - lock (Sync) - { - DiscoverNativeEngine(); - return [.. RegisteredEngines]; - } - } - } - - /// - /// The engine to use. With a specific , only an engine whose - /// actually covers it is returned — never an engine that would - /// silently misread a script it doesn't know (e.g. Apple Vision guessing at Devanagari), even if that engine is - /// otherwise the platform's native/preferred one. With no language ("Automatic"), the platform's native engine - /// (Apple Vision on macOS: zero install, GPU/NPU accelerated) is preferred, since the document's script isn't - /// known ahead of time; picking a non-Latin-only language from the preset editor is what routes to Tesseract. - /// - public static IOcrEngine? GetDefault(string? language = null) - { - lock (Sync) - { - DiscoverNativeEngine(); - - if (!string.IsNullOrWhiteSpace(language)) - return RegisteredEngines.FirstOrDefault(e => e.IsAvailable && Supports(e, language)); - - var native = RegisteredEngines.FirstOrDefault(e => e.Id.Equals("vision", StringComparison.OrdinalIgnoreCase) && e.IsAvailable); - return native ?? RegisteredEngines.FirstOrDefault(e => e.IsAvailable); - } - } - - /// - /// The exact code to pass to 's for Filee's - /// code: Apple Vision needs its own full BCP-47 entry ("en" → "en-US"), engines that - /// map Filee's codes themselves (Tesseract) get the code unchanged. - /// - public static string? ResolveLanguage(IOcrEngine? engine, string? language) - { - if (engine is null || string.IsNullOrWhiteSpace(language)) - return language; - return engine.SupportedLanguages.FirstOrDefault(l => - l.Equals(language, StringComparison.OrdinalIgnoreCase) - || l.StartsWith(language + "-", StringComparison.OrdinalIgnoreCase)) ?? language; - } - - /// - /// Whether any registered engine (installed or not) claims to support — for the - /// preset editor's language picker, so it never offers a language no engine could ever attempt. Use - /// to find the engine that can actually run it right now. - /// - public static bool AnyEngineSupports(string language) - { - lock (Sync) - { - DiscoverNativeEngine(); - return RegisteredEngines.Any(e => Supports(e, language)); - } - } - - private static bool Supports(IOcrEngine engine, string language) => - engine.SupportedLanguages.Count == 0 - || engine.SupportedLanguages.Any(l => l.Equals(language, StringComparison.OrdinalIgnoreCase) - || l.StartsWith(language + "-", StringComparison.OrdinalIgnoreCase)); - - /// Registers the platform's native engine (Apple Vision) the first time it's needed. - private static void DiscoverNativeEngine() - { - if (!OperatingSystem.IsMacOS() || RegisteredEngines.Any(e => e.Id.Equals("vision", StringComparison.OrdinalIgnoreCase))) - return; - try - { - var asm = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "Filee.Platform.MacOS"); - if (asm is null) - { - try - { - asm = System.Runtime.Loader.AssemblyLoadContext.Default.LoadFromAssemblyName(new System.Reflection.AssemblyName("Filee.Platform.MacOS")); - } - catch - { - var baseDir = AppContext.BaseDirectory; - var path = System.IO.Path.Combine(baseDir, "Filee.Platform.MacOS.dll"); - if (System.IO.File.Exists(path)) - asm = System.Runtime.Loader.AssemblyLoadContext.Default.LoadFromAssemblyPath(path); - } - } - - var macEngineType = asm?.GetType("Filee.Platform.MacOS.MacOsVisionOcrEngine") - ?? Type.GetType("Filee.Platform.MacOS.MacOsVisionOcrEngine, Filee.Platform.MacOS"); - if (macEngineType is not null && Activator.CreateInstance(macEngineType) is IOcrEngine macEngine && macEngine.IsAvailable) - RegisteredEngines.Add(macEngine); - } - catch - { - // Ignore discovery failure - } - } - - public static IOcrEngine? GetById(string id) - { - lock (Sync) - { - return RegisteredEngines.FirstOrDefault(e => e.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); - } - } - - /// Clears registered engines for test isolation. - internal static void ResetForTesting() - { - lock (Sync) - { - RegisteredEngines.Clear(); - } - } -} diff --git a/src/Filee.Core/Ocr/OcrResult.cs b/src/Filee.Core/Ocr/OcrResult.cs deleted file mode 100644 index 2d60ac93c..000000000 --- a/src/Filee.Core/Ocr/OcrResult.cs +++ /dev/null @@ -1,39 +0,0 @@ -// The result of an IOcrEngine.RecognizeAsync call: text and boxes in image pixel coordinates, grouped into words -// and lines so a caller can either read the plain text or reconstruct layout (PdfDocumentReader scales these boxes -// back onto the PDF page, one shared baseline per line rather than per word). - -namespace Filee.Core.Ocr; - -/// -/// A bounding box in image pixel coordinates (Left/Top to Right/Bottom). -/// -public readonly record struct OcrRect(double Left, double Top, double Right, double Bottom) -{ - public double Width => Math.Max(0, Right - Left); - public double Height => Math.Max(0, Bottom - Top); -} - -/// -/// A recognized word with its text, bounding box, and recognition confidence. -/// -public sealed record OcrWord(string Text, OcrRect Box, float Confidence = 1.0f); - -/// -/// A recognized line composed of words. -/// -public sealed record OcrLine(string Text, IReadOnlyList Words, OcrRect Box); - -/// -/// The complete OCR result of an image or page. -/// -public sealed record OcrPageResult( - string Text, - IReadOnlyList Lines, - IReadOnlyList Words, - int ImageWidth, - int ImageHeight, - string? Language = null) -{ - public static OcrPageResult Empty(int width = 0, int height = 0) => - new("", [], [], width, height); -} diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index bef2e490f..b543e8b93 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -477,16 +477,6 @@ public sealed class DocumentOptions /// public bool LeaveOutPictures { get; set; } - /// - /// Optical Character Recognition for scanned pages and images. - /// - public bool Ocr { get; set; } - - /// - /// Optical Character Recognition language code (e.g. "en-US", "ko-KR"). Null uses the system default. - /// - public string? OcrLanguage { get; set; } - public DocumentOptions Clone() => (DocumentOptions)MemberwiseClone(); } diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index b3cd7de15..048dc125a 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -403,10 +403,6 @@ public static void Apply(AppSettings settings) if (settings.SchemaVersion < 21) InsertBefore(settings.EnginePriority, "wic", settings.EnginePriority.Contains("ffmpeg") ? "ffmpeg" : "pandoc"); - // v33: built-in "image-ocr" engine (image to text/docx/md via Vision/Tesseract) before external converters. - if (settings.SchemaVersion < 33 && !settings.EnginePriority.Contains("image-ocr")) - InsertBefore(settings.EnginePriority, "image-ocr", settings.EnginePriority.Contains("ffmpeg") ? "ffmpeg" : "pandoc"); - if (settings.Triggers.Count == 0) settings.Triggers = TriggerGesture.Defaults(); diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs index de4200a03..96fafe618 100644 --- a/src/Filee.Engines/EngineRegistry.cs +++ b/src/Filee.Engines/EngineRegistry.cs @@ -67,6 +67,7 @@ public static IReadOnlyList CreateAll(EngineEnvironment env, ILogger new EbookConverter(), new PandocConverter(), new GhostscriptConverter(), + new VisionConverter(), // before Tesseract: a Mac's OCR step (Tesseract reads the languages Vision doesn't) new TesseractConverter(), new CalibreConverter(), new LibreOfficeConverter(env, loggers?.CreateLogger()), diff --git a/src/Filee.Engines/Filee.Engines.csproj b/src/Filee.Engines/Filee.Engines.csproj index 2d667725a..b3121143c 100644 --- a/src/Filee.Engines/Filee.Engines.csproj +++ b/src/Filee.Engines/Filee.Engines.csproj @@ -39,7 +39,6 @@ - diff --git a/src/Filee.Engines/Infrastructure/EngineInstaller.cs b/src/Filee.Engines/Infrastructure/EngineInstaller.cs index e9a6741e1..7b7b50f2a 100644 --- a/src/Filee.Engines/Infrastructure/EngineInstaller.cs +++ b/src/Filee.Engines/Infrastructure/EngineInstaller.cs @@ -54,11 +54,11 @@ public EngineInstaller(HttpClient http, string? root = null, IReadOnlyDictionary } /// True when every component is installed (any version). - public bool IsInstalled(EnginePackage package) => package.Components.Count > 0 && package.Components.All(c => _components.ContainsKey(c) && Marker(c) is not null); + public bool IsInstalled(EnginePackage package) => package.Components.All(c => _components.ContainsKey(c) && Marker(c) is not null); /// True when every component is installed in the version engines.json pins. public bool IsUpToDate(EnginePackage package) => - package.Components.Count > 0 && package.Components.All(c => _components.TryGetValue(c, out var component) + package.Components.All(c => _components.TryGetValue(c, out var component) && string.Equals(Marker(c), component.Sha256, StringComparison.OrdinalIgnoreCase)); /// Downloads and unpacks the components that are missing or outdated. diff --git a/src/Filee.Engines/Ocr/AppleVision.cs b/src/Filee.Engines/Ocr/AppleVision.cs new file mode 100644 index 000000000..0a69ae6fa --- /dev/null +++ b/src/Filee.Engines/Ocr/AppleVision.cs @@ -0,0 +1,247 @@ +// Text recognition with Apple's Vision framework (VNRecognizeTextRequest), part of macOS: nothing to download, and +// it runs on the Neural Engine or GPU. Called through the Objective-C runtime (objc_msgSend) so Filee needs no +// bridging library; the framework is loaded only on macOS, the first time it is asked for. +// +// A CGRect (four doubles, 32 bytes) comes back in registers on Apple silicon but through memory on Intel Macs, where +// it needs objc_msgSend_stret. Objects Filee creates with alloc/init are released, the ones Vision hands back are +// autoreleased and go with the autorelease pool of each call. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Filee.Engines.Ocr; + +/// Reads the text of a picture: what needs from Apple Vision. +internal interface ITextRecognizer +{ + /// True when the recognizer can run on this computer. + bool IsAvailable { get; } + + /// The languages it reads, as Vision identifiers (en-US, zh-Hans). + IReadOnlyCollection Languages { get; } + + /// The lines of text in , in (most likely first). + IReadOnlyList Recognize(string picture, IReadOnlyList languages, CancellationToken cancellationToken); +} + +/// Apple Vision's text recognition, accurate level, newest revision of this macOS. +[SupportedOSPlatform("macos")] +internal sealed class AppleVision : ITextRecognizer +{ + private const string Vision = "/System/Library/Frameworks/Vision.framework/Vision"; + private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + private const string ObjC = "/usr/lib/libobjc.A.dylib"; + private const uint Utf8 = 0x08000100; // kCFStringEncodingUTF8 + private const nint Accurate = 0; // VNRequestTextRecognitionLevelAccurate + + private static readonly Lazy Loaded = new(() => + NativeLibrary.TryLoad(Vision, out _) && objc_getClass("VNRecognizeTextRequest") != 0); + + private static readonly Lazy> Supported = new(ReadLanguages); + + public bool IsAvailable => Loaded.Value; + + public IReadOnlyCollection Languages => IsAvailable ? Supported.Value : []; + + public IReadOnlyList Recognize(string picture, IReadOnlyList languages, CancellationToken cancellationToken) + { + if (!IsAvailable) + throw new PlatformNotSupportedException("Apple Vision text recognition is not available."); + var pool = New("NSAutoreleasePool"); + nint request = 0; + nint handler = 0; + var path = CFStringCreateWithCString(0, picture, Utf8); + try + { + request = NewRequest(); + SendVoid(request, Sel("setUsesLanguageCorrection:"), 1); + var wanted = Send(objc_getClass("NSMutableArray"), Sel("array")); + foreach (var language in languages) + { + var name = CFStringCreateWithCString(0, language, Utf8); + Send(wanted, Sel("addObject:"), name); + CFRelease(name); + } + Send(request, Sel("setRecognitionLanguages:"), wanted); + + var url = Send(objc_getClass("NSURL"), Sel("fileURLWithPath:"), path); + var options = Send(objc_getClass("NSDictionary"), Sel("dictionary")); + handler = Send(Send(objc_getClass("VNImageRequestHandler"), Sel("alloc")), Sel("initWithURL:options:"), url, options); + var requests = Send(objc_getClass("NSArray"), Sel("arrayWithObject:"), request); + bool done; + nint error; + using (cancellationToken.Register(() => Send(request, Sel("cancel")))) + done = Perform(handler, Sel("performRequests:error:"), requests, out error); + cancellationToken.ThrowIfCancellationRequested(); + if (!done) + throw new InvalidOperationException($"Apple Vision couldn't read the picture. {Describe(error)}".Trim()); + return Lines(Send(request, Sel("results"))); + } + finally + { + if (handler != 0) + Send(handler, Sel("release")); + if (request != 0) + Send(request, Sel("release")); + if (path != 0) + CFRelease(path); + Send(pool, Sel("drain")); + } + } + + /// The recognized lines, each with a box per word (estimated from the line when Vision gives none). + private static List Lines(nint observations) + { + var lines = new List(); + var count = observations == 0 ? 0 : (long)Send(observations, Sel("count")); + for (long i = 0; i < count; i++) + { + var observation = Send(observations, Sel("objectAtIndex:"), (nint)i); + var candidates = Send(observation, Sel("topCandidates:"), 1); + if (candidates == 0 || Send(candidates, Sel("count")) == 0) + continue; + var candidate = Send(candidates, Sel("objectAtIndex:"), 0); + var text = ToText(Send(candidate, Sel("string"))); + if (string.IsNullOrWhiteSpace(text)) + continue; + var box = Rect(observation, Sel("boundingBox")); + var estimate = OcrLine.Estimate(text, box.X, box.Y, box.Width, box.Height); + var words = new List(); + var ranges = OcrLine.WordRanges(text); + for (var w = 0; w < ranges.Count; w++) + { + var (start, length) = ranges[w]; + // NSString ranges count UTF-16 units, as .NET strings do. + var wordBox = SendRange(candidate, Sel("boundingBoxForRange:error:"), new NSRange((nuint)start, (nuint)length), out _); + if (wordBox == 0) + { + words.Add(estimate.Words[w]); + continue; + } + var rect = Rect(wordBox, Sel("boundingBox")); + words.Add(new OcrWord(text.Substring(start, length), rect.X, rect.Width)); + } + lines.Add(new OcrLine(box.X, box.Y, box.Width, box.Height, words)); + } + return lines; + } + + /// A request at the accurate level and the newest revision this macOS has (owned: release it). + private static nint NewRequest() + { + var type = objc_getClass("VNRecognizeTextRequest"); + var request = Send(Send(type, Sel("alloc")), Sel("init")); + Send(request, Sel("setRecognitionLevel:"), Accurate); + // Without it the revision depends on the SDK the program was built with, which for .NET's host may be old. + var revisions = Send(type, Sel("supportedRevisions")); + if (revisions != 0) + { + var newest = Send(revisions, Sel("lastIndex")); + if (newest != nint.MaxValue) // NSNotFound + Send(request, Sel("setRevision:"), newest); + } + return request; + } + + private static IReadOnlyCollection ReadLanguages() + { + var pool = New("NSAutoreleasePool"); + var request = NewRequest(); + try + { + var list = SendWithError(request, Sel("supportedRecognitionLanguagesAndReturnError:"), out _); + if (list == 0) + return VisionLanguages.Fallback; + var languages = new List(); + var count = (long)Send(list, Sel("count")); + for (long i = 0; i < count; i++) + { + if (ToText(Send(list, Sel("objectAtIndex:"), (nint)i)) is { Length: > 0 } language) + languages.Add(language); + } + return languages.Count > 0 ? languages : VisionLanguages.Fallback; + } + finally + { + Send(request, Sel("release")); + Send(pool, Sel("drain")); + } + } + + private static string? Describe(nint error) => + error == 0 ? null : ToText(Send(error, Sel("localizedDescription"))); + + private static string? ToText(nint nsString) => + nsString == 0 ? null : Marshal.PtrToStringUTF8(Send(nsString, Sel("UTF8String"))); + + private static nint New(string type) => Send(Send(objc_getClass(type), Sel("alloc")), Sel("init")); + + private static nint Sel(string name) => sel_registerName(name); + + private static CGRect Rect(nint receiver, nint selector) + { + if (RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + SendRectStret(out var rect, receiver, selector); + return rect; + } + return SendRect(receiver, selector); + } + + [StructLayout(LayoutKind.Sequential)] + private struct CGRect + { + public double X; + public double Y; + public double Width; + public double Height; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct NSRange(nuint location, nuint length) + { + public readonly nuint Location = location; + public readonly nuint Length = length; + } + + [DllImport(CoreFoundation)] + private static extern nint CFStringCreateWithCString(nint allocator, [MarshalAs(UnmanagedType.LPUTF8Str)] string value, uint encoding); + + [DllImport(CoreFoundation)] + private static extern void CFRelease(nint value); + + [DllImport(ObjC)] + private static extern nint objc_getClass([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC)] + private static extern nint sel_registerName([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector, nint argument); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector, nint first, nint second); + + /// A method with one BOOL argument and no result. + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern void SendVoid(nint receiver, nint selector, byte value); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint SendWithError(nint receiver, nint selector, out nint error); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint SendRange(nint receiver, nint selector, NSRange range, out nint error); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool Perform(nint receiver, nint selector, nint requests, out nint error); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern CGRect SendRect(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend_stret")] + private static extern void SendRectStret(out CGRect result, nint receiver, nint selector); +} diff --git a/src/Filee.Engines/Ocr/ImageOcrConverter.cs b/src/Filee.Engines/Ocr/ImageOcrConverter.cs deleted file mode 100644 index 5ca18689a..000000000 --- a/src/Filee.Engines/Ocr/ImageOcrConverter.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Image → TXT, DOCX and Markdown via whichever OCR engine OcrEngineRegistry resolves (Apple Vision on macOS, -// Tesseract everywhere else). Allows dropping scans, photos or screenshots directly and extracting editable text. - -using System.Text; -using Filee.Core.Conversion; -using Filee.Core.Ocr; -using Filee.Engines.Hwp.Hwpx; -using Filee.Engines.Hwp.Hwpx.Docx; -using Filee.Engines.Infrastructure; - -namespace Filee.Engines.Ocr; - -/// -/// Converts raster images directly to text documents using Optical Character Recognition. -/// -public sealed class ImageOcrConverter : IConverter -{ - private static readonly string[] ImageFormats = ["png", "jpg", "webp", "tiff", "bmp"]; - - public string Id => "image-ocr"; - public string DisplayName => "Image OCR (built-in)"; - public int MaxParallelism => 0; - - public IReadOnlyList Edges { get; } = - ImageFormats.SelectMany(from => new ConversionEdge[] - { - new(from, "txt", 15, DirectOnly: true), - new(from, "docx", 15, DirectOnly: true), - new(from, "md", 15, DirectOnly: true), - }).ToList(); - - public EngineStatus GetStatus() - { - var engine = OcrEngineRegistry.GetDefault(); - if (engine is { IsAvailable: true }) - return EngineStatus.Available($"Image → TXT, DOCX, Markdown ({engine.DisplayName})", EngineVersions.BuiltIn); - - return EngineStatus.Unavailable("engine.reason.not_installed", "No supported OCR engine found on this system."); - } - - public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) - { - // Unlike PdfDocumentReader (where OCR is one option among several for an already-readable document), - // this converter exists only because OCR is the sole way to get text out of an image: Document.Ocr - // (off by default, so an ordinary PDF conversion isn't surprised by OCR) does not gate it. - var language = step.Preset.Document.OcrLanguage; - var engine = OcrEngineRegistry.GetDefault(language); - if (engine is null || !engine.IsAvailable) - throw new InvalidOperationException(string.IsNullOrWhiteSpace(language) - ? "No OCR engine is installed. Install Tesseract in Settings → Engines." - : $"No installed OCR engine recognizes '{language}'. Install Tesseract with that language's trained data in Settings → Engines."); - - progress?.Report(0.1); - var result = await engine.RecognizeAsync(step.InputPath, OcrEngineRegistry.ResolveLanguage(engine, language), cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - progress?.Report(0.7); - - if (string.IsNullOrWhiteSpace(result.Text)) - throw new InvalidOperationException("No text could be recognized in the image."); - - var output = step.Output.Allocate(step.To); - if (output is null) - return []; - - switch (step.To) - { - case "txt": - await File.WriteAllTextAsync(output, result.Text, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), cancellationToken); - break; - - case "md": - var mdContent = string.Join("\n\n", result.Lines.Select(l => l.Text)); - await File.WriteAllTextAsync(output, mdContent, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); - break; - - case "docx": - var doc = new HDocument(); - var section = new HSection(); - foreach (var line in result.Lines) - { - var p = new HParagraph(); - p.Inlines.Add(new HText(line.Text, default)); - section.Blocks.Add(p); - } - doc.Sections.Add(section); - await Task.Run(() => DocxWriter.Write(doc, output), cancellationToken); - break; - - default: - throw new NotSupportedException($"Target format {step.To} is not supported by Image OCR."); - } - - progress?.Report(1.0); - return [output]; - } -} diff --git a/src/Filee.Engines/Ocr/OcrTextLayer.cs b/src/Filee.Engines/Ocr/OcrTextLayer.cs new file mode 100644 index 000000000..3f2bc2e74 --- /dev/null +++ b/src/Filee.Engines/Ocr/OcrTextLayer.cs @@ -0,0 +1,373 @@ +// The invisible text of a searchable PDF written by Filee itself (Apple Vision, VisionConverter): each page is the +// picture as it looks, with the recognized words on top in text render mode 3 (neither filled nor stroked), placed and +// stretched over the words of the picture so they can be searched, selected and copied. PdfDocumentReader and +// TesseractConverter.PagesWithText read it like Tesseract's own layer. +// +// The words are written in a font without glyph shapes made here (GlyphlessFont): every UTF-16 unit is one character +// code (Identity-H), drawn as an empty glyph half an em wide, and the ToUnicode map gives each code back as itself, +// so any script works without a real font in the file. + +using System.Globalization; +using System.IO.Compression; +using System.Text; +using PdfSharp.Drawing; +using PdfSharp.Pdf; +using PdfSharp.Pdf.Advanced; + +namespace Filee.Engines.Ocr; + +/// +/// One recognized line. Positions are fractions of the picture (0–1) from its bottom-left corner, as Apple Vision +/// reports them. +/// +/// The line's words, left to right. +internal sealed record OcrLine(double Left, double Bottom, double Width, double Height, IReadOnlyList Words) +{ + /// + /// Splits into words at white space and spreads them over the line by their length: for + /// a line whose words have no boxes of their own. + /// + public static OcrLine Estimate(string text, double left, double bottom, double width, double height) + { + var words = new List(); + var ranges = WordRanges(text); + var letters = ranges.Sum(r => r.Length); + if (letters == 0) + return new OcrLine(left, bottom, width, height, words); + // A space between two words counts as one letter of width. + var unit = width / (letters + ranges.Count - 1); + var x = left; + foreach (var (start, length) in ranges) + { + words.Add(new OcrWord(text.Substring(start, length), x, unit * length)); + x += unit * (length + 1); + } + return new OcrLine(left, bottom, width, height, words); + } + + /// Where each word of starts and how long it is, in UTF-16 units. + public static List<(int Start, int Length)> WordRanges(string text) + { + var ranges = new List<(int, int)>(); + var start = -1; + for (var i = 0; i <= text.Length; i++) + { + var space = i == text.Length || char.IsWhiteSpace(text[i]); + if (!space && start < 0) + start = i; + else if (space && start >= 0) + { + ranges.Add((start, i - start)); + start = -1; + } + } + return ranges; + } +} + +/// One recognized word: its text and where it is across the line (fractions of the picture's width). +internal sealed record OcrWord(string Text, double Left, double Width); + +/// Writes searchable PDFs: pictures as pages, each with its recognized text as an invisible layer. +internal sealed class OcrPdfWriter : IDisposable +{ + /// The font's resource name on every page. + internal const string FontResource = "/FOcr"; + + /// Width of every character code, in thousandths of the font size. + private const int CharacterWidth = 500; + + private readonly PdfDocument _document = new(); + private PdfDictionary? _font; + + public int PageCount => _document.PageCount; + + /// + /// Adds (a JPEG or PNG) as a page of × + /// points with on it as invisible text. + /// + public void AddPage(string picture, double width, double height, IReadOnlyList lines) + { + var page = _document.AddPage(); + page.Width = XUnit.FromPoint(width); + page.Height = XUnit.FromPoint(height); + using (var gfx = XGraphics.FromPdfPage(page)) + using (var image = XImage.FromFile(picture)) + gfx.DrawImage(image, 0, 0, width, height); + var text = TextLayer(lines, width, height); + if (text.Length == 0) + return; + var resources = page.Resources; + if (resources.Elements.GetDictionary("/Font") is not { } fonts) + { + fonts = new PdfDictionary(_document); + resources.Elements["/Font"] = fonts; + } + fonts.Elements.SetReference(FontResource, _font ??= CreateFont()); + page.Contents.AppendContent().CreateStream(Encoding.ASCII.GetBytes(text)); + } + + public void Save(string path) => _document.Save(path); + + public void Dispose() => _document.Dispose(); + + /// + /// The content stream of the invisible text: one font size per line (its height), the baseline at the line's + /// bottom, and each word stretched (Tz) from where it starts to where the next one starts, its trailing space + /// included, so text extraction finds the words and the spaces between them. Empty when there is nothing to write. + /// + internal static string TextLayer(IReadOnlyList lines, double pageWidth, double pageHeight) + { + var content = new StringBuilder(); + foreach (var line in lines) + { + var size = line.Height * pageHeight; + if (size <= 0) + continue; + var baseline = line.Bottom * pageHeight; + for (var i = 0; i < line.Words.Count; i++) + { + var word = line.Words[i]; + if (string.IsNullOrWhiteSpace(word.Text)) + continue; + var x = word.Left * pageWidth; + var last = i == line.Words.Count - 1; + var text = last ? word.Text : word.Text + " "; + var width = word.Width * pageWidth; + if (!last && (line.Words[i + 1].Left - word.Left) * pageWidth is var toNext && toNext > width) + width = toNext; + if (width <= 0) + continue; + var scale = 100 * width / (text.Length * size * CharacterWidth / 1000); + content.Append(CultureInfo.InvariantCulture, $"{FontResource} {N(size)} Tf {N(scale)} Tz 1 0 0 1 {N(x)} {N(baseline)} Tm <"); + foreach (var unit in text) + content.Append(((int)unit).ToString("X4", CultureInfo.InvariantCulture)); + content.Append("> Tj\n"); + } + } + return content.Length == 0 ? "" : "q\nBT\n3 Tr\n" + content + "ET\nQ\n"; + } + + private static string N(double value) => Math.Round(value, 3).ToString("0.###", CultureInfo.InvariantCulture); + + /// The Type 0 font with the glyphless TrueType font in it, once per document. + private PdfDictionary CreateFont() + { + var file = Indirect(GlyphlessFont.Bytes, compress: true); + file.Elements.SetInteger("/Length1", GlyphlessFont.Bytes.Length); + + var descriptor = Indirect(); + descriptor.Elements.SetName("/Type", "/FontDescriptor"); + descriptor.Elements.SetName("/FontName", "/" + GlyphlessFont.Name); + descriptor.Elements.SetInteger("/Flags", 5); // fixed pitch, symbolic + descriptor.Elements["/FontBBox"] = Numbers(0, 0, CharacterWidth, 1000); + descriptor.Elements.SetInteger("/ItalicAngle", 0); + descriptor.Elements.SetInteger("/Ascent", 1000); + descriptor.Elements.SetInteger("/Descent", -1); + descriptor.Elements.SetInteger("/CapHeight", 1000); + descriptor.Elements.SetInteger("/StemV", 80); + descriptor.Elements.SetReference("/FontFile2", file); + + // Every character code shows glyph 1, the empty one. + var map = new byte[2 * 65536]; + for (var i = 1; i < map.Length; i += 2) + map[i] = 1; + var cidToGid = Indirect(map, compress: true); + + var info = new PdfDictionary(_document); + info.Elements["/Registry"] = new PdfString("Adobe"); + info.Elements["/Ordering"] = new PdfString("Identity"); + info.Elements.SetInteger("/Supplement", 0); + + var cidFont = Indirect(); + cidFont.Elements.SetName("/Type", "/Font"); + cidFont.Elements.SetName("/Subtype", "/CIDFontType2"); + cidFont.Elements.SetName("/BaseFont", "/" + GlyphlessFont.Name); + cidFont.Elements["/CIDSystemInfo"] = info; + cidFont.Elements.SetReference("/FontDescriptor", descriptor); + cidFont.Elements.SetInteger("/DW", CharacterWidth); + cidFont.Elements.SetReference("/CIDToGIDMap", cidToGid); + + var toUnicode = Indirect(Encoding.ASCII.GetBytes(ToUnicode), compress: false); + + var font = Indirect(); + font.Elements.SetName("/Type", "/Font"); + font.Elements.SetName("/Subtype", "/Type0"); + font.Elements.SetName("/BaseFont", "/" + GlyphlessFont.Name); + font.Elements.SetName("/Encoding", "/Identity-H"); + var descendants = new PdfArray(_document); + descendants.Elements.Add(cidFont.Reference!); + font.Elements["/DescendantFonts"] = descendants; + font.Elements.SetReference("/ToUnicode", toUnicode); + return font; + } + + /// Each two-byte code is the UTF-16 unit it shows (a character outside the BMP is two codes). + private const string ToUnicode = """ + /CIDInit /ProcSet findresource begin + 12 dict begin + begincmap + /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def + /CMapName /Adobe-Identity-UCS def + /CMapType 2 def + 1 begincodespacerange + <0000> + endcodespacerange + 1 beginbfrange + <0000> <0000> + endbfrange + endcmap + CMapName currentdict /CMap defineresource pop + end + end + """; + + private PdfDictionary Indirect(byte[]? stream = null, bool compress = false) + { + var dictionary = new PdfDictionary(_document); + _document.Internals.AddObject(dictionary); + if (stream is not null) + { + if (compress) + { + using var buffer = new MemoryStream(); + using (var zlib = new ZLibStream(buffer, CompressionLevel.Optimal, leaveOpen: true)) + zlib.Write(stream); + dictionary.CreateStream(buffer.ToArray()); + dictionary.Elements.SetName("/Filter", "/FlateDecode"); + } + else + { + dictionary.CreateStream(stream); + } + } + return dictionary; + } + + private PdfArray Numbers(params int[] values) + { + var array = new PdfArray(_document); + foreach (var value in values) + array.Elements.Add(new PdfInteger(value)); + return array; + } +} + +/// +/// A TrueType font with two empty glyphs, 500 of 1000 units wide: what the invisible text of an OCR layer is set in. +/// Built in code, so there is no font file to ship. +/// +internal static class GlyphlessFont +{ + public const string Name = "FileeGlyphless"; + + public static readonly byte[] Bytes = Build(); + + private static byte[] Build() + { + // Tables in the order of their tags (the directory must be sorted), as 16-bit words (a 32-bit field is two). + var tables = new SortedDictionary(StringComparer.Ordinal) + { + // Version 0, one subtable (Windows, Unicode BMP) at 12: format 4, one segment that maps only 0xFFFF. + ["cmap"] = Words(0, 1, 3, 1, 0, 12, 4, 24, 0, 2, 2, 0, 0, 0xFFFF, 0, 0xFFFF, 1, 0), + ["glyf"] = [], + // Version 1.0, revision 1.0, checksum adjustment (set below), magic number, flags, 1000 units per em, + // created and modified (none), bounding box 0 0 500 1000, style, smallest size, direction, short loca, + // glyph format. + ["head"] = Words(1, 0, 1, 0, 0, 0, 0x5F0F, 0x3CF5, 3, 1000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 500, 1000, 0, 3, 2, 0, 0), + // Version 1.0, ascender 1000, descender and gap 0, widest 500, bearings and extent 0, upright caret, + // reserved, metric format, two metrics. + ["hhea"] = Words(1, 0, 1000, 0, 0, 500, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2), + ["hmtx"] = Words(500, 0, 500, 0), + ["loca"] = Words(0, 0, 0), + // Version 1.0, two glyphs, no points or contours, two zones. + ["maxp"] = Words(1, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0), + ["name"] = Words(0, 0, 6), + // Version 3.0 (no glyph names), upright, no underline, fixed pitch. + ["post"] = Words(3, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), + }; + + var count = tables.Count; + var power = 1; + var log = 0; + while (power * 2 <= count) + { + power *= 2; + log++; + } + var font = new BigEndian(); + font.U32(0x00010000); + font.U16(count); + font.U16(power * 16); + font.U16(log); + font.U16(count * 16 - power * 16); + var offset = 12 + 16 * count; + var headOffset = 0; + foreach (var (tag, data) in tables) + { + font.Bytes(Encoding.ASCII.GetBytes(tag)); + font.U32(Checksum(data)); + font.U32((uint)offset); + font.U32((uint)data.Length); + if (tag == "head") + headOffset = offset; + offset += Padded(data.Length); + } + foreach (var data in tables.Values) + { + font.Bytes(data); + font.Bytes(new byte[Padded(data.Length) - data.Length]); + } + var bytes = font.ToArray(); + var adjustment = unchecked(0xB1B0AFBA - Checksum(bytes)); + bytes[headOffset + 8] = (byte)(adjustment >> 24); + bytes[headOffset + 9] = (byte)(adjustment >> 16); + bytes[headOffset + 10] = (byte)(adjustment >> 8); + bytes[headOffset + 11] = (byte)adjustment; + return bytes; + } + + private static int Padded(int length) => (length + 3) & ~3; + + private static uint Checksum(byte[] data) + { + uint sum = 0; + for (var i = 0; i < data.Length; i += 4) + { + uint value = 0; + for (var j = 0; j < 4; j++) + value = (value << 8) | (i + j < data.Length ? data[i + j] : 0u); + sum = unchecked(sum + value); + } + return sum; + } + + private static byte[] Words(params int[] values) + { + var writer = new BigEndian(); + foreach (var value in values) + writer.U16(value); + return writer.ToArray(); + } + + private sealed class BigEndian + { + private readonly MemoryStream _stream = new(); + + public void U16(int value) + { + _stream.WriteByte((byte)(value >> 8)); + _stream.WriteByte((byte)value); + } + + public void U32(uint value) + { + U16((int)(value >> 16)); + U16((int)(value & 0xFFFF)); + } + + public void Bytes(byte[] value) => _stream.Write(value); + + public byte[] ToArray() => _stream.ToArray(); + } +} diff --git a/src/Filee.Engines/Ocr/TesseractConverter.cs b/src/Filee.Engines/Ocr/TesseractConverter.cs index f0f370130..cd504ab30 100644 --- a/src/Filee.Engines/Ocr/TesseractConverter.cs +++ b/src/Filee.Engines/Ocr/TesseractConverter.cs @@ -100,7 +100,8 @@ public async Task> ConvertAsync(ConversionStep step, IProg return []; if (step.From == "pdf") { - await RecognizePdfAsync(ocr, step, scratch, output, progress, cancellationToken); + await RecognizePdfAsync(step, scratch, output, Runs, (picture, page, token) => ocr.ToPdfAsync(picture, page, Dpi, token), + progress, cancellationToken); } else { @@ -116,10 +117,15 @@ public async Task> ConvertAsync(ConversionStep step, IProg /// /// The pages without text become recognized scans, the others are copied. A PDF whose pages all have text is - /// copied as it is, with a note. + /// copied as it is, with a note. Also Apple Vision's way (). /// - private static async Task RecognizePdfAsync(Run ocr, ConversionStep step, string scratch, string output, - IProgress? progress, CancellationToken cancellationToken) + /// How many pages are recognized at the same time. + /// + /// Reads the picture of a page (p{n}.png in , at ) and writes it + /// as a searchable one-page PDF (p{n}.pdf there); it gets both names relative to . + /// + internal static async Task RecognizePdfAsync(ConversionStep step, string scratch, string output, int parallel, + Func recognize, IProgress? progress, CancellationToken cancellationToken) { var bytes = await File.ReadAllBytesAsync(step.InputPath, cancellationToken); var hasText = PagesWithText(step.InputPath); @@ -135,7 +141,7 @@ private static async Task RecognizePdfAsync(Run ocr, ConversionStep step, string // Page by page: PDFium draws a page only when one of the Tesseract runs is free, and a page's picture is deleted // as soon as its PDF exists, so a 300-page scan never has more than Runs pictures in the work folder. var options = new RenderOptions(Dpi: Dpi, WithAnnotations: true, WithFormFill: true, BackgroundColor: SKColors.White); - using var free = new SemaphoreSlim(Runs); + using var free = new SemaphoreSlim(parallel); var runs = new List(); var done = 0; try @@ -164,7 +170,7 @@ private static async Task RecognizePdfAsync(Run ocr, ConversionStep step, string { try { - await ocr.ToPdfAsync($"p{page}.png", $"p{page}", Dpi, cancellationToken); + await recognize($"p{page}.png", $"p{page}", cancellationToken); File.Delete(picture); progress?.Report(0.05 + 0.9 * Interlocked.Increment(ref done) / scans.Count); } diff --git a/src/Filee.Engines/Ocr/TesseractOcrEngine.cs b/src/Filee.Engines/Ocr/TesseractOcrEngine.cs deleted file mode 100644 index c8eed04a5..000000000 --- a/src/Filee.Engines/Ocr/TesseractOcrEngine.cs +++ /dev/null @@ -1,242 +0,0 @@ -// Tesseract OCR engine, run as a subprocess with ArgumentList and a timeout, found via the user's own copy or on -// the system path. Cross-platform fallback for platforms without a native OCR API (Windows, Linux) and for scripts -// Apple Vision does not recognize (Devanagari, Cyrillic beyond Russian/Ukrainian, Thai, Greek, Arabic script, ...). - -using System.Globalization; -using Filee.Core.Ocr; -using Filee.Engines.Infrastructure; - -namespace Filee.Engines.Ocr; - -/// -/// Optical Character Recognition engine powered by Tesseract (Apache-2.0), run as a subprocess. Covers every -/// platform and, with the matching .traineddata installed, scripts Apple Vision does not (Devanagari, Cyrillic -/// beyond Russian/Ukrainian, Thai, Greek, Arabic script, ...). -/// -public sealed class TesseractOcrEngine : IOcrEngine -{ - private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2); - - public string Id => "tesseract"; - public string DisplayName => "Tesseract OCR"; - - public bool IsAvailable => LocateExecutable() is not null; - - /// - /// Filee's language codes this engine can be asked to recognize, limited to what Tesseract's own trained-data - /// set actually ships (tessdata_fast/best): asking for a code outside this list always fails, so the UI and - /// only offer what can really work. - /// - public IReadOnlyList SupportedLanguages => [.. LanguageMap.Keys]; - - /// Filee language code (the OCR language picker, BCP-47-ish) → Tesseract's own 3-letter code. - private static readonly IReadOnlyDictionary LanguageMap = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["en"] = "eng", - ["ne"] = "nep", - ["hi"] = "hin", - ["zh-Hans"] = "chi_sim", - ["zh-Hant"] = "chi_tra", - ["ja"] = "jpn", - ["ko"] = "kor", - ["es"] = "spa", - ["fr"] = "fra", - ["de"] = "deu", - ["it"] = "ita", - ["pt"] = "por", - ["ru"] = "rus", - ["ar"] = "ara", - ["th"] = "tha", - ["el"] = "ell", - ["uk"] = "ukr", - ["tr"] = "tur", - ["vi"] = "vie", - ["id"] = "ind", - ["pl"] = "pol", - ["nl"] = "nld", - ["sv"] = "swe", - ["da"] = "dan", - ["fi"] = "fin", - ["no"] = "nor", - ["cs"] = "ces", - ["sk"] = "slk", - ["hu"] = "hun", - ["ro"] = "ron", - ["bg"] = "bul", - ["hr"] = "hrv", - ["sr-Cyrl"] = "srp", - ["sr-Latn"] = "srp_latn", - ["sl"] = "slv", - ["et"] = "est", - ["lv"] = "lav", - ["lt"] = "lit", - ["be"] = "bel", - ["sa"] = "san", - ["mr"] = "mar", - ["ta"] = "tam", - ["te"] = "tel", - ["ur"] = "urd", - ["fa"] = "fas", - ["ps"] = "pus", - ["sd"] = "snd", - ["ug"] = "uig", - ["af"] = "afr", - ["az"] = "aze", - ["bs"] = "bos", - ["ca"] = "cat", - ["cy"] = "cym", - ["eu"] = "eus", - ["ga"] = "gle", - ["gl"] = "glg", - ["is"] = "isl", - ["ka"] = "kat", - ["ku"] = "kmr", - ["la"] = "lat", - ["lb"] = "ltz", - ["mi"] = "mri", - ["mk"] = "mkd", - ["mn"] = "mon", - ["ms"] = "msa", - ["mt"] = "mlt", - ["oc"] = "oci", - ["qu"] = "que", - ["sq"] = "sqi", - ["sw"] = "swa", - ["tl"] = "fil", - ["uz"] = "uzb", - ["kk"] = "kaz", - ["ky"] = "kir", - ["tg"] = "tgk", - ["tt"] = "tat", - }; - - /// Tesseract's own code for a Filee language code, or null when Tesseract has no matching trained data. - public static string? MapLanguage(string? language) - { - if (string.IsNullOrWhiteSpace(language)) - return "eng"; - return LanguageMap.TryGetValue(language.Trim(), out var code) ? code : null; - } - - public static string? LocateExecutable() => - EngineEnvironment.OwnProgram("tesseract", "tesseract.exe") - ?? EngineEnvironment.BundledProgram("tesseract", "tesseract.exe") - ?? EngineEnvironment.SystemProgram("tesseract", "tesseract.exe"); - - /// - /// The tessdata folder next to : Homebrew, apt and the official Windows installer - /// all keep trained-data files at one of these relative locations. Null lets tesseract fall back to its own - /// compiled-in default (TESSDATA_PREFIX or the path it was built with), same as running it from a shell. - /// - public static string? LocateTessdataDir(string executable) - { - var bin = Path.GetDirectoryName(executable); - if (bin is null) - return null; - var root = Path.GetFullPath(Path.Combine(bin, "..")); - string[] candidates = - [ - Path.Combine(bin, "tessdata"), - Path.Combine(root, "share", "tessdata"), - Path.Combine(root, "share", "tesseract-ocr", "5", "tessdata"), - Path.Combine(root, "share", "tesseract-ocr", "4.00", "tessdata"), - "/usr/share/tesseract-ocr/5/tessdata", - "/usr/share/tesseract-ocr/4.00/tessdata", - "/usr/share/tessdata", - ]; - return candidates.FirstOrDefault(c => Directory.Exists(c) && Directory.EnumerateFiles(c, "*.traineddata").Any()); - } - - /// Whether has its .traineddata installed, not just a known mapping. - public bool HasLanguageData(string? language) - { - var exe = LocateExecutable(); - var code = MapLanguage(language); - if (exe is null || code is null) - return false; - var dir = LocateTessdataDir(exe); - if (dir is null) - return true; // Can't check; let tesseract itself report a missing language if it turns out to be wrong. - return File.Exists(Path.Combine(dir, $"{code}.traineddata")); - } - - public async Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default) - { - if (!File.Exists(imagePath)) - throw new FileNotFoundException("Image file not found for OCR.", imagePath); - - var exe = LocateExecutable() ?? throw new InvalidOperationException("Tesseract is not installed."); - var code = MapLanguage(language) - ?? throw new InvalidOperationException($"Tesseract has no trained data for language '{language}'."); - - var args = new List { imagePath, "stdout", "-l", code }; - if (LocateTessdataDir(exe) is { } tessdata) - args.AddRange(["--tessdata-dir", tessdata]); - args.Add("tsv"); - - var result = await ProcessRunner.RunAsync(exe, [.. args], Timeout, cancellationToken); - if (result.ExitCode != 0) - { - throw new InvalidOperationException(result.StandardError.Contains("Failed loading language", StringComparison.OrdinalIgnoreCase) - ? $"Tesseract has no trained data installed for '{code}'. Install the {language} language pack and try again." - : $"Tesseract could not read the image. {result.StandardError.Trim()}"); - } - - return ParseTsv(result.StandardOutput, code); - } - - private static OcrPageResult ParseTsv(string tsv, string language) - { - var words = new List(); - var lineGroups = new List<(string Key, List Words)>(); - var imgWidth = 0; - var imgHeight = 0; - - foreach (var row in tsv.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - var cols = row.Split('\t'); - // level, page_num, block_num, par_num, line_num, word_num, left, top, width, height, conf, text - if (cols.Length < 12) - continue; - if (cols[0] == "1") - { - // Page-level row: left/top/width/height is the whole image, needed to scale word boxes back - // to the source page (PdfDocumentReader) even when the recognized text doesn't fill the image. - int.TryParse(cols[8], out imgWidth); - int.TryParse(cols[9], out imgHeight); - continue; - } - if (cols[0] != "5") - continue; - var text = cols[11].Trim(); - if (text.Length == 0) - continue; - if (!double.TryParse(cols[6], NumberStyles.Float, CultureInfo.InvariantCulture, out var left) - || !double.TryParse(cols[7], NumberStyles.Float, CultureInfo.InvariantCulture, out var top) - || !double.TryParse(cols[8], NumberStyles.Float, CultureInfo.InvariantCulture, out var width) - || !double.TryParse(cols[9], NumberStyles.Float, CultureInfo.InvariantCulture, out var height)) - continue; - float.TryParse(cols[10], NumberStyles.Float, CultureInfo.InvariantCulture, out var conf); - - var word = new OcrWord(text, new OcrRect(left, top, left + width, top + height), Math.Max(0, conf) / 100f); - words.Add(word); - - var lineKey = string.Join('.', cols[1], cols[2], cols[3], cols[4]); - var group = lineGroups.Count > 0 && lineGroups[^1].Key == lineKey ? lineGroups[^1] : (lineKey, []); - if (lineGroups.Count == 0 || lineGroups[^1].Key != lineKey) - lineGroups.Add(group); - group.Words.Add(word); - } - - var lines = lineGroups.Select(g => - { - var text = string.Join(' ', g.Words.Select(w => w.Text)); - var box = new OcrRect( - g.Words.Min(w => w.Box.Left), g.Words.Min(w => w.Box.Top), - g.Words.Max(w => w.Box.Right), g.Words.Max(w => w.Box.Bottom)); - return new OcrLine(text, g.Words, box); - }).ToList(); - - return new OcrPageResult(string.Join('\n', lines.Select(l => l.Text)), lines, words, imgWidth, imgHeight, language); - } -} diff --git a/src/Filee.Engines/Ocr/VisionConverter.cs b/src/Filee.Engines/Ocr/VisionConverter.cs new file mode 100644 index 000000000..4539df007 --- /dev/null +++ b/src/Filee.Engines/Ocr/VisionConverter.cs @@ -0,0 +1,180 @@ +// Text recognition (OCR) with Apple Vision on macOS: the same "Recognize text" step as TesseractConverter (scans and +// pictures → searchable PDF, read on into DOCX, TXT, Markdown and HWPX), with macOS's own recognizer, so a Mac needs no +// download for the languages Vision reads. It has the same OCR edges as Tesseract and is registered before it, so on +// a Mac it is the one the route planner takes; a language Vision doesn't read (VisionLanguages) goes to Tesseract when +// that is installed, and is refused with a hint to install it otherwise. On Windows and Linux it is unavailable. +// +// PDFs go page by page like Tesseract's (TesseractConverter.RecognizePdfAsync: pages with text are copied, scans are +// rendered at 300 dpi); each recognized page is the picture with the words as an invisible text layer (OcrPdfWriter). +// A JPEG picture goes into the PDF as it is; other pictures (and a JPEG turned by its EXIF orientation) as PNG, every +// page of a TIFF. + +using Filee.Core.Conversion; +using ImageMagick; + +namespace Filee.Engines.Ocr; + +/// Scans and pictures → searchable PDF through Apple Vision (macOS). +public sealed class VisionConverter : IConverter +{ + private readonly ITextRecognizer? _recognizer; + private readonly IConverter _tesseract; + + public VisionConverter() + : this(OperatingSystem.IsMacOS() ? new AppleVision() : null, new TesseractConverter()) + { + } + + /// Reads the languages Vision doesn't, when it is installed. + internal VisionConverter(ITextRecognizer? recognizer, IConverter tesseract) + { + _recognizer = recognizer; + _tesseract = tesseract; + } + + /// Vision uses the Neural Engine or the GPU: a few pages at a time keep it busy. + internal static int Runs => Math.Clamp(Environment.ProcessorCount, 1, 2); + + public string Id => "vision"; + public string DisplayName => "Apple Vision"; + + /// One file at a time; its pages are recognized in parallel. + public int MaxParallelism => 1; + + public IReadOnlyList Edges { get; } = + [.. TesseractConverter.ImageSources.Select(f => new ConversionEdge(f, "pdf", Ocr: true)), new("pdf", "pdf", Ocr: true)]; + + public EngineStatus GetStatus() + { + if (_recognizer is null) + return EngineStatus.Unavailable("engine.reason.unsupported_platform"); + if (!_recognizer.IsAvailable) + return EngineStatus.Unavailable("engine.reason.not_installed", "Vision.framework"); + return EngineStatus.Available(string.Join(", ", _recognizer.Languages), $"macOS {Environment.OSVersion.Version}"); + } + + /// + /// True when Apple Vision reads text in (a Tesseract code, PdfOptions.OcrLanguage) on + /// this computer, so that language needs no download. + /// + public bool Reads(string code) => + _recognizer is { IsAvailable: true } recognizer && VisionLanguages.For(code, recognizer.Languages) is not null; + + public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + { + var recognizer = _recognizer is { IsAvailable: true } available ? available + : throw new PlatformNotSupportedException("Apple Vision text recognition runs on macOS only."); + var language = OcrLanguages.Find(step.Preset.Pdf.OcrLanguage) ?? OcrLanguages.Find(OcrLanguages.Default)!; + if (VisionLanguages.For(language.Code, recognizer.Languages) is not { } languages) + { + if (_tesseract.GetStatus().IsAvailable) + return await _tesseract.ConvertAsync(step, progress, cancellationToken); + throw new ConversionRefusedException("error.ocr_needs_tesseract", language.NativeName); + } + + var scratch = Path.Combine(step.WorkDirectory, "vision-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(scratch); + progress?.Report(0.05); + var output = step.Output.Allocate("pdf"); + if (output is null) + return []; + if (step.From == "pdf") + { + await TesseractConverter.RecognizePdfAsync(step, scratch, output, Runs, (picture, page, token) => Task.Run(() => + { + var path = Path.Combine(scratch, picture); + var (width, height) = PixelSize(path); + using var writer = new OcrPdfWriter(); + writer.AddPage(path, Points(width, TesseractConverter.Dpi), Points(height, TesseractConverter.Dpi), + Recognize(recognizer, path, languages, token)); + writer.Save(Path.Combine(scratch, page + ".pdf")); + }, token), progress, cancellationToken); + } + else + { + await Task.Run(() => + { + using var writer = new OcrPdfWriter(); + foreach (var page in Pictures(step.InputPath, step.From, scratch)) + { + cancellationToken.ThrowIfCancellationRequested(); + writer.AddPage(page.Path, Points(page.Width, page.Dpi), Points(page.Height, page.Dpi), + Recognize(recognizer, page.Path, languages, cancellationToken)); + } + writer.Save(output); + }, cancellationToken); + } + progress?.Report(1); + return [output]; + } + + /// + /// Reads one picture. When Vision fails with a second language (English) next to the one asked for, it reads again + /// with that one alone. + /// + internal static IReadOnlyList Recognize(ITextRecognizer recognizer, string picture, IReadOnlyList languages, + CancellationToken cancellationToken) + { + try + { + return recognizer.Recognize(picture, languages, cancellationToken); + } + catch (InvalidOperationException) when (languages.Count > 1 && !cancellationToken.IsCancellationRequested) + { + return recognizer.Recognize(picture, [languages[0]], cancellationToken); + } + } + + /// A picture's pages: where each is, its size in pixels and its resolution. + internal sealed record Picture(string Path, uint Width, uint Height, double Dpi); + + /// + /// The pages of a picture as files PDFsharp embeds: a JPEG as it is (unless EXIF turns it), anything else as PNG + /// in , turned upright; every page of a TIFF, the first frame of anything else. + /// + internal static IEnumerable Pictures(string path, string format, string scratch) + { + if (format == "jpg") + { + using var info = new MagickImage(); + info.Ping(path); + if (info.Orientation is OrientationType.Undefined or OrientationType.TopLeft) + { + yield return new Picture(path, info.Width, info.Height, Dpi(info)); + yield break; + } + } + using var frames = new MagickImageCollection(); + frames.Read(path, new MagickReadSettings { FrameIndex = format == "tiff" ? null : 0u }); + var index = 0; + foreach (var frame in frames) + { + var dpi = Dpi(frame); + frame.AutoOrient(); + var png = Path.Combine(scratch, $"picture-{index++}.png"); + frame.Write(png, MagickFormat.Png); + yield return new Picture(png, frame.Width, frame.Height, dpi); + } + } + + /// + /// The resolution the picture says it has, or 300 dpi when it says none (a scanner's resolution; like + /// Tesseract, which would otherwise assume 70 dpi and make a huge page). + /// + internal static double Dpi(IMagickImage image) + { + var density = image.Density; + if (density.X <= 1) + return TesseractConverter.Dpi; + return density.Units == DensityUnit.PixelsPerCentimeter ? density.X * 2.54 : density.X; + } + + private static double Points(uint pixels, double dpi) => pixels * 72.0 / dpi; + + private static (uint Width, uint Height) PixelSize(string path) + { + using var info = new MagickImage(); + info.Ping(path); + return (info.Width, info.Height); + } +} diff --git a/src/Filee.Engines/Ocr/VisionLanguages.cs b/src/Filee.Engines/Ocr/VisionLanguages.cs new file mode 100644 index 000000000..98c562c21 --- /dev/null +++ b/src/Filee.Engines/Ocr/VisionLanguages.cs @@ -0,0 +1,59 @@ +// Which languages of the preset (PdfOptions.OcrLanguage, a Tesseract code from OcrLanguages) Apple Vision reads, as +// the language identifiers Vision takes. Vision says at run time which it reads (more with each macOS version); a +// language it doesn't is read by Tesseract instead (VisionConverter). + +namespace Filee.Engines.Ocr; + +internal static class VisionLanguages +{ + /// Tesseract code → Vision identifiers it may go by, the usual one first. + private static readonly Dictionary ByCode = new() + { + ["eng"] = ["en-US"], + ["deu"] = ["de-DE"], + ["fra"] = ["fr-FR"], + ["spa"] = ["es-ES"], + ["por"] = ["pt-BR"], + ["ita"] = ["it-IT"], + ["nld"] = ["nl-NL"], + ["pol"] = ["pl-PL"], + ["ces"] = ["cs-CZ"], + ["ron"] = ["ro-RO"], + ["swe"] = ["sv-SE"], + ["dan"] = ["da-DK"], + ["nor"] = ["nb-NO", "no-NO"], + ["tur"] = ["tr-TR"], + ["ind"] = ["id-ID"], + ["msa"] = ["ms-MY"], + ["rus"] = ["ru-RU"], + ["ukr"] = ["uk-UA"], + ["ara"] = ["ar-SA"], + ["tha"] = ["th-TH"], + ["vie"] = ["vi-VT", "vi-VN"], + ["kor"] = ["ko-KR"], + ["jpn"] = ["ja-JP"], + ["chi_sim"] = ["zh-Hans"], + ["chi_tra"] = ["zh-Hant"], + }; + + /// The languages Vision reads on every macOS Filee runs on (13 and later), when it can't be asked. + internal static readonly IReadOnlyList Fallback = + ["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant", "ko-KR", "ja-JP", "ru-RU", "uk-UA"]; + + /// + /// The languages to ask Vision for when the text is in : that language, then English + /// (scans in other languages often have English words in them), or null when Vision doesn't read it. + /// + /// What this Mac's Vision reads (). + public static IReadOnlyList? For(string code, IReadOnlyCollection supported) + { + if (!ByCode.TryGetValue(code, out var candidates)) + return null; + var match = candidates.Select(c => supported.FirstOrDefault(s => string.Equals(s, c, StringComparison.OrdinalIgnoreCase))) + .FirstOrDefault(s => s is not null); + if (match is null) + return null; + var english = supported.FirstOrDefault(s => string.Equals(s, "en-US", StringComparison.OrdinalIgnoreCase)); + return english is null || english == match ? [match] : [match, english]; + } +} diff --git a/src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs b/src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs deleted file mode 100644 index 61fe78099..000000000 --- a/src/Filee.Platform.MacOS/MacOsVisionOcrEngine.cs +++ /dev/null @@ -1,302 +0,0 @@ -// OCR through Apple's Vision framework (VNRecognizeTextRequest), called directly via the Objective-C runtime so -// Filee.Platform.MacOS stays free of an Objective-C bridging library. Zero install size and GPU/NPU accelerated; -// the preferred engine on macOS whenever its SupportedLanguages cover what was asked for. - -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using System.Text; -using Filee.Core.Ocr; - -namespace Filee.Platform.MacOS; - -/// Optical Character Recognition engine powered by Apple's Vision framework (macOS only). -[SupportedOSPlatform("macos")] -public sealed class MacOsVisionOcrEngine : IOcrEngine -{ - private static readonly Lazy VisionAvailable = new(() => - { - if (!OperatingSystem.IsMacOS()) - return false; - try - { - NativeLibrary.Load("/System/Library/Frameworks/Vision.framework/Vision"); - return objc_getClass("VNRecognizeTextRequest") != 0; - } - catch - { - return false; - } - }); - - public string Id => "vision"; - - public string DisplayName => "Apple Vision"; - - public bool IsAvailable => VisionAvailable.Value; - - public IReadOnlyList SupportedLanguages { get; } = - [ - "en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR", "ru-RU", "uk-UA" - ]; - - public Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default) - { - if (!IsAvailable) - return Task.FromResult(OcrPageResult.Empty()); - - return Task.Run(() => - { - cancellationToken.ThrowIfCancellationRequested(); - using var pool = new AutoreleasePool(); - - var (width, height) = GetImageDimensions(imagePath); - if (width <= 0 || height <= 0) - (width, height) = (1000, 1000); - - var nsPath = CFStringCreateWithCString(0, imagePath, 0x08000100); - if (nsPath == 0) - return OcrPageResult.Empty(width, height); - - nint request = 0; - nint handler = 0; - try - { - var urlClass = objc_getClass("NSURL"); - var fileUrl = Send(urlClass, sel_registerName("fileURLWithPath:"), nsPath); - - var reqClass = objc_getClass("VNRecognizeTextRequest"); - request = Send(Send(reqClass, sel_registerName("alloc")), sel_registerName("init")); - SendInteger(request, sel_registerName("setRecognitionLevel:"), 0); // 0 = VNRequestTextRecognitionLevelAccurate - SendBool(request, sel_registerName("setUsesLanguageCorrection:"), true); - - if (!string.IsNullOrEmpty(language)) - { - var langString = CFStringCreateWithCString(0, language, 0x08000100); - if (langString != 0) - { - var arrayClass = objc_getClass("NSArray"); - var langsArray = Send(arrayClass, sel_registerName("arrayWithObject:"), langString); - Send(request, sel_registerName("setRecognitionLanguages:"), langsArray); - CFRelease(langString); - } - } - else if (OperatingSystem.IsMacOSVersionAtLeast(13)) - { - SendBool(request, sel_registerName("setAutomaticallyDetectsLanguage:"), true); - } - - var dictClass = objc_getClass("NSDictionary"); - var emptyDict = Send(dictClass, sel_registerName("dictionary")); - var handlerClass = objc_getClass("VNImageRequestHandler"); - handler = Send(Send(handlerClass, sel_registerName("alloc")), sel_registerName("initWithURL:options:"), fileUrl, emptyDict); - - var arrayClass2 = objc_getClass("NSArray"); - var requestsArray = Send(arrayClass2, sel_registerName("arrayWithObject:"), request); - - if (!SendPerform(handler, sel_registerName("performRequests:error:"), requestsArray, out _)) - return OcrPageResult.Empty(width, height); - - var results = Send(request, sel_registerName("results")); - if (results == 0) - return OcrPageResult.Empty(width, height); - - var count = (int)CFArrayGetCount(results); - var lines = new List(); - var allWords = new List(); - var fullText = new StringBuilder(); - - for (var i = 0; i < count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - var obs = CFArrayGetValueAtIndex(results, i); - var candidates = Send(obs, sel_registerName("topCandidates:"), 1); - if (candidates == 0 || CFArrayGetCount(candidates) == 0) - continue; - - var cand = CFArrayGetValueAtIndex(candidates, 0); - var nsStr = Send(cand, sel_registerName("string")); - var utf8Ptr = Send(nsStr, sel_registerName("UTF8String")); - var text = Marshal.PtrToStringUTF8(utf8Ptr); - if (string.IsNullOrWhiteSpace(text)) - continue; - - var conf = SendFloat(cand, sel_registerName("confidence")); - var normBox = SendRect(obs, sel_registerName("boundingBox")); - - // Apple Vision returns normalized coordinates with bottom-left origin: - // normBox.X (0..1 from left), normBox.Y (0..1 from bottom), - // normBox.Width, normBox.Height. - // Convert to pixel coordinates with top-left origin: - var pixelLeft = normBox.X * width; - var pixelRight = (normBox.X + normBox.Width) * width; - var pixelTop = (1.0 - normBox.Y - normBox.Height) * height; - var pixelBottom = (1.0 - normBox.Y) * height; - var lineBox = new OcrRect(pixelLeft, pixelTop, pixelRight, pixelBottom); - - // Estimate word boxes along the line - var rawWords = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var wordsInLine = new List(); - if (rawWords.Length == 1) - { - var word = new OcrWord(rawWords[0], lineBox, conf); - wordsInLine.Add(word); - allWords.Add(word); - } - else if (rawWords.Length > 1) - { - var totalChars = rawWords.Sum(w => w.Length); - var currentX = pixelLeft; - var usableWidth = Math.Max(0, lineBox.Width); - for (var w = 0; w < rawWords.Length; w++) - { - var wStr = rawWords[w]; - var wWidth = totalChars > 0 ? (usableWidth * wStr.Length / (double)totalChars) : (usableWidth / rawWords.Length); - var wBox = new OcrRect(currentX, pixelTop, currentX + wWidth, pixelBottom); - var word = new OcrWord(wStr, wBox, conf); - wordsInLine.Add(word); - allWords.Add(word); - currentX += wWidth; - } - } - - lines.Add(new OcrLine(text, wordsInLine, lineBox)); - fullText.AppendLine(text); - } - - return new OcrPageResult(fullText.ToString().TrimEnd(), lines, allWords, width, height, language); - } - finally - { - if (handler != 0) - SendVoid(handler, sel_registerName("release")); - if (request != 0) - SendVoid(request, sel_registerName("release")); - CFRelease(nsPath); - } - }, cancellationToken); - } - - private static (int Width, int Height) GetImageDimensions(string path) - { - try - { - var nsImageClass = objc_getClass("NSImage"); - var nsPath = CFStringCreateWithCString(0, path, 0x08000100); - if (nsPath == 0) - return (0, 0); - try - { - var alloc = Send(nsImageClass, sel_registerName("alloc")); - var image = Send(alloc, sel_registerName("initWithContentsOfFile:"), nsPath); - if (image == 0) - return (0, 0); - try - { - var size = SendSize(image, sel_registerName("size")); - return ((int)Math.Round(size.Width), (int)Math.Round(size.Height)); - } - finally - { - SendVoid(image, sel_registerName("release")); - } - } - finally - { - CFRelease(nsPath); - } - } - catch - { - return (0, 0); - } - } - - [StructLayout(LayoutKind.Sequential)] - private struct CGRect - { - public double X; - public double Y; - public double Width; - public double Height; - } - - private readonly struct AutoreleasePool : IDisposable - { - private readonly nint _pool = Send(Send(objc_getClass("NSAutoreleasePool"), sel_registerName("alloc")), sel_registerName("init")); - public AutoreleasePool() { } - public void Dispose() => SendVoid(_pool, sel_registerName("drain")); - } - - [StructLayout(LayoutKind.Sequential)] - private struct CGSize - { - public double Width; - public double Height; - } - - private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; - private const string ObjC = "/usr/lib/libobjc.A.dylib"; - - [DllImport(CoreFoundation)] - private static extern nint CFArrayGetCount(nint array); - - [DllImport(CoreFoundation)] - private static extern nint CFArrayGetValueAtIndex(nint array, nint index); - - [DllImport(CoreFoundation)] - private static extern nint CFStringCreateWithCString(nint allocator, [MarshalAs(UnmanagedType.LPUTF8Str)] string value, uint encoding); - - [DllImport(CoreFoundation)] - private static extern void CFRelease(nint obj); - - [DllImport(ObjC)] - private static extern nint objc_getClass([MarshalAs(UnmanagedType.LPUTF8Str)] string name); - - [DllImport(ObjC)] - private static extern nint sel_registerName([MarshalAs(UnmanagedType.LPUTF8Str)] string name); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern nint Send(nint receiver, nint selector); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern nint Send(nint receiver, nint selector, nint argument); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern nint Send(nint receiver, nint selector, nint arg1, nint arg2); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - [return: MarshalAs(UnmanagedType.I1)] - private static extern bool SendBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern void SendInteger(nint receiver, nint selector, nint value); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - [return: MarshalAs(UnmanagedType.I1)] - private static extern bool SendPerform(nint receiver, nint selector, nint requests, out nint error); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern float SendFloat(nint receiver, nint selector); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern CGRect SendRectArm64(nint receiver, nint selector); - - [DllImport(ObjC, EntryPoint = "objc_msgSend_stret")] - private static extern void SendRectStret(out CGRect result, nint receiver, nint selector); - - private static CGRect SendRect(nint receiver, nint selector) - { - if (RuntimeInformation.ProcessArchitecture == Architecture.X64) - { - SendRectStret(out var rect, receiver, selector); - return rect; - } - return SendRectArm64(receiver, selector); - } - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern CGSize SendSize(nint receiver, nint selector); - - [DllImport(ObjC, EntryPoint = "objc_msgSend")] - private static extern void SendVoid(nint receiver, nint selector); -} diff --git a/tests/Filee.App.Tests/EnginesPageTests.cs b/tests/Filee.App.Tests/EnginesPageTests.cs index 813b0c69f..f1c6edec4 100644 --- a/tests/Filee.App.Tests/EnginesPageTests.cs +++ b/tests/Filee.App.Tests/EnginesPageTests.cs @@ -111,14 +111,4 @@ public void Only_LibreOffice_has_settings_and_they_change_the_saved_switches() vm.Dispose(); } } - - [AvaloniaFact] - public void Tesseract_package_exists_and_offers_own_copy_or_guidance() - { - TestServices.EnsureInitialized("en"); - var downloads = AppHost.Get(); - var tesseract = downloads.Packages.FirstOrDefault(p => p.Package.Id == "tesseract"); - Assert.NotNull(tesseract); - Assert.True(tesseract.CanUseOwnCopy || tesseract.IsInstalled); - } } diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index 94c085833..130f1ce68 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -1171,6 +1171,10 @@ public void Preset_editor_shows_and_saves_text_recognition(string language) Assert.True(vm.Ocr); Assert.Equal("eng", vm.OcrLanguage.Value); Assert.Equal("eng", vm.OcrLanguageChoices[0].Value); // English first, the others by their own names + // On a Mac Apple Vision reads the common languages, with nothing to download. + Assert.Equal(loc[OperatingSystem.IsMacOS() ? "presets.ocr_hint_macos" : "presets.ocr_hint"], vm.OcrHint); + if (OperatingSystem.IsMacOS()) + Assert.Null(vm.OcrLanguageChoices.Single(l => l.Value == "deu").Detail); var window = new PresetEditorWindow { DataContext = vm, Height = 1400 }; window.Show(); diff --git a/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs b/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs deleted file mode 100644 index 528efd98b..000000000 --- a/tests/Filee.Core.Tests/OcrEngineRegistryTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Tests for OcrEngineRegistry, OcrPageResult, and OCR document options. - -using Filee.Core.Ocr; -using Filee.Core.Presets; - -namespace Filee.Core.Tests; - -public class OcrEngineRegistryTests : IDisposable -{ - public OcrEngineRegistryTests() => OcrEngineRegistry.ResetForTesting(); - public void Dispose() => OcrEngineRegistry.ResetForTesting(); - - private sealed class MockOcrEngine(string id, string name, bool available, IReadOnlyList languages) : IOcrEngine - { - public string Id => id; - public string DisplayName => name; - public bool IsAvailable => available; - public IReadOnlyList SupportedLanguages => languages; - - public Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default) - { - var word = new OcrWord("test", new OcrRect(0, 0, 10, 10), 0.99f); - var line = new OcrLine("test", [word], new OcrRect(0, 0, 10, 10)); - return Task.FromResult(new OcrPageResult("test", [line], [word], 100, 100, language)); - } - } - - [Fact] - public void OcrRect_computes_width_and_height_correctly() - { - var rect = new OcrRect(10, 20, 110, 70); - Assert.Equal(100, rect.Width); - Assert.Equal(50, rect.Height); - } - - [Fact] - public void OcrPageResult_Empty_has_zero_words_and_lines() - { - var empty = OcrPageResult.Empty(200, 300); - Assert.Empty(empty.Text); - Assert.Empty(empty.Lines); - Assert.Empty(empty.Words); - Assert.Equal(200, empty.ImageWidth); - Assert.Equal(300, empty.ImageHeight); - } - - [Fact] - public void Register_and_retrieve_engine() - { - var mock = new MockOcrEngine("mock-ocr", "Mock OCR", true, ["en", "ne"]); - OcrEngineRegistry.Register(mock); - - Assert.Contains(OcrEngineRegistry.All, e => e.Id == "mock-ocr"); - Assert.NotNull(OcrEngineRegistry.GetById("mock-ocr")); - Assert.Same(mock, OcrEngineRegistry.GetById("mock-ocr")); - } - - [Fact] - public void DocumentOptions_defaults_and_cloning() - { - var options = new DocumentOptions(); - Assert.False(options.Ocr); - Assert.Null(options.OcrLanguage); - - options.Ocr = true; - options.OcrLanguage = "ne"; - - var clone = options.Clone(); - Assert.True(clone.Ocr); - Assert.Equal("ne", clone.OcrLanguage); - } - - [Fact] - public void GetDefault_routes_a_language_to_the_engine_that_actually_supports_it() - { - // A language no other test's mock claims, so this isn't order-dependent on the shared registry. - var tesseract = new MockOcrEngine("tesseract", "Tesseract OCR", true, ["th", "el"]); - OcrEngineRegistry.Register(tesseract); - - var thaiEngine = OcrEngineRegistry.GetDefault("th"); - Assert.NotNull(thaiEngine); - Assert.Equal("tesseract", thaiEngine.Id); - } - - [Fact] - public void GetDefault_returns_null_when_no_available_engine_supports_the_language() - { - var unrelated = new MockOcrEngine("unrelated-mock", "Unrelated", true, ["fr"]); - OcrEngineRegistry.Register(unrelated); - - Assert.Null(OcrEngineRegistry.GetDefault("a-language-nothing-claims")); - } -} diff --git a/tests/Filee.Core.Tests/UserDataStoreTests.cs b/tests/Filee.Core.Tests/UserDataStoreTests.cs index d773a360f..4b06239b4 100644 --- a/tests/Filee.Core.Tests/UserDataStoreTests.cs +++ b/tests/Filee.Core.Tests/UserDataStoreTests.cs @@ -415,7 +415,7 @@ public void Version_1_settings_are_upgraded_to_the_current_engine_list() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", "subtitle", "wic", "image-ocr", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", "subtitle", "wic", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } @@ -467,7 +467,7 @@ public void Version_4_libraries_get_spreadsheet_and_presentation_profiles() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", "subtitle", "wic", "image-ocr", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", "subtitle", "wic", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice"], store.Settings.EnginePriority); var ids = store.Profiles.Select(p => p.Id).ToList(); Assert.Equal(ids.IndexOf("office") + 1, ids.IndexOf("spreadsheets")); Assert.Equal(ids.IndexOf("office") + 2, ids.IndexOf("presentations")); @@ -642,8 +642,8 @@ public void Version_13_settings_get_the_data_engine_after_email() // ... and, from schema 15, the 3D model engine before Pandoc (no CAD or archive engine in this list), from // schema 18 the PDF slides engine there too (no PDF text engine), from schema 21 the WIC engine (no FFmpeg), - // from schema 25 the geospatial engine after the 3D models engine, from schema 33 the image OCR engine before Pandoc. - Assert.Equal(["magick", "email", "data", "vector", "model3d", "geo", "pdf-slides", "wic", "image-ocr", "pandoc", "libreoffice"], store.Settings.EnginePriority); + // from schema 25 the geospatial engine after the 3D models engine. + Assert.Equal(["magick", "email", "data", "vector", "model3d", "geo", "pdf-slides", "wic", "pandoc", "libreoffice"], store.Settings.EnginePriority); } [Fact] @@ -927,7 +927,7 @@ public void Version_24_settings_get_the_geo_engine_after_the_3d_models_engine() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "cad", "model3d", "geo", "archive", "image-ocr", "pandoc", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "cad", "model3d", "geo", "archive", "pandoc", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } @@ -1252,7 +1252,7 @@ public void Version_17_settings_get_the_pdf_slides_engine_after_pdf_text() var store = new UserDataStore(dir.Path); store.Load(); - Assert.Equal(["magick", "pdfium", "docx-writer", "pdf-text", "pdf-slides", "email", "geo", "wic", "image-ocr", "pandoc", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "pdfium", "docx-writer", "pdf-text", "pdf-slides", "email", "geo", "wic", "pandoc", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } @@ -1267,21 +1267,7 @@ public void Version_20_settings_get_the_wic_engine_after_the_other_built_in_engi store.Load(); // Not near the top: the HWPX writer stays among the ranks that win ties (see EbookTests). - Assert.Equal(["magick", "pdfium", "hwpx-writer", "subtitle", "wic", "image-ocr", "ffmpeg", "geo", "pandoc", "libreoffice"], store.Settings.EnginePriority); - Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); - } - - [Fact] - public void Version_32_settings_get_the_image_ocr_engine_before_ffmpeg() - { - using var dir = new TempDir(); - File.WriteAllText(Path.Combine(dir.Path, "settings.json"), - """{ "schemaVersion": 32, "enginePriority": ["magick", "pdfium", "ffmpeg", "pandoc", "libreoffice"] }"""); - - var store = new UserDataStore(dir.Path); - store.Load(); - - Assert.Equal(["magick", "pdfium", "image-ocr", "ffmpeg", "pandoc", "libreoffice"], store.Settings.EnginePriority); + Assert.Equal(["magick", "pdfium", "hwpx-writer", "subtitle", "wic", "ffmpeg", "geo", "pandoc", "libreoffice"], store.Settings.EnginePriority); Assert.Equal(AppSettings.CurrentSchemaVersion, store.Settings.SchemaVersion); } diff --git a/tests/Filee.Engines.Tests/OcrTests.cs b/tests/Filee.Engines.Tests/OcrTests.cs index ea0d514b6..3679adde9 100644 --- a/tests/Filee.Engines.Tests/OcrTests.cs +++ b/tests/Filee.Engines.Tests/OcrTests.cs @@ -102,9 +102,10 @@ public void An_ocr_layer_a_page_of_text_and_a_drawn_note_are_left_alone() public class OcrTests(EngineFixture fx) : IClassFixture { - private static readonly bool Installed = TesseractConverter.Locate() is not null; + /// On a Mac Apple Vision takes the OCR step (VisionConverter, AppleVisionTests), so these are Tesseract's elsewhere. + private static readonly bool Installed = TesseractConverter.Locate() is not null && !new VisionConverter().GetStatus().IsAvailable; - private const string Skip = "Tesseract is not installed (pwsh build/fetch-engines.ps1 -Only 7zip,tesseract)"; + private const string Skip = "Tesseract is not installed (pwsh build/fetch-engines.ps1 -Only 7zip,tesseract), or Apple Vision reads instead"; private static readonly string? Arial = EngineEnvironment.FirstExisting(@"C:\Windows\Fonts\arial.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf"); diff --git a/tests/Filee.Engines.Tests/TesseractOcrTests.cs b/tests/Filee.Engines.Tests/TesseractOcrTests.cs deleted file mode 100644 index 01135b71b..000000000 --- a/tests/Filee.Engines.Tests/TesseractOcrTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Tests for TesseractOcrEngine language mapping and image recognition. - -using Filee.Core.Ocr; -using Filee.Engines.Ocr; -using SkiaSharp; - -namespace Filee.Engines.Tests; - -public sealed class TesseractOcrTests -{ - // Pure mapping logic: runs even where Tesseract itself isn't installed (CI). - [Theory] - [InlineData("en", "eng")] - [InlineData("ne", "nep")] - [InlineData("zh-Hans", "chi_sim")] - [InlineData("zh-Hant", "chi_tra")] - [InlineData(null, "eng")] - public void MapLanguage_maps_known_codes(string? filee, string tesseract) => - Assert.Equal(tesseract, TesseractOcrEngine.MapLanguage(filee)); - - [Fact] - public void MapLanguage_returns_null_for_a_code_with_no_trained_data() - { - // An unmapped code must be reported as unsupported rather than guessed at. - Assert.Null(TesseractOcrEngine.MapLanguage("xal")); - } - - [Fact] - public async Task RecognizesTextInAGeneratedImage() - { - var engine = new TesseractOcrEngine(); - Assert.SkipUnless(engine.IsAvailable, "Tesseract is not installed on this machine."); - - var imagePath = Path.Combine(Path.GetTempPath(), $"filee-tesseract-test-{Guid.NewGuid():N}.png"); - try - { - using (var bitmap = new SKBitmap(400, 120)) - using (var canvas = new SKCanvas(bitmap)) - using (var font = new SKFont(SKTypeface.Default, 48)) - using (var paint = new SKPaint { Color = SKColors.Black, IsAntialias = true }) - { - canvas.Clear(SKColors.White); - canvas.DrawText("Filee OCR Test", 10, 70, font, paint); - using var image = SKImage.FromBitmap(bitmap); - using var data = image.Encode(SKEncodedImageFormat.Png, 100); - using var stream = File.OpenWrite(imagePath); - data.SaveTo(stream); - } - - var result = await engine.RecognizeAsync(imagePath, "en", TestContext.Current.CancellationToken); - - Assert.Contains("Filee", result.Text, StringComparison.OrdinalIgnoreCase); - Assert.NotEmpty(result.Words); - Assert.True(result.ImageWidth > 0 && result.ImageHeight > 0); - } - finally - { - File.Delete(imagePath); - } - } - - [Fact] - public async Task RecognizeAsync_throws_a_clear_error_for_an_unmapped_language() - { - var engine = new TesseractOcrEngine(); - Assert.SkipUnless(engine.IsAvailable, "Tesseract is not installed on this machine."); - - // Any existing file: the unmapped-language check happens before the image is read. - var imagePath = Path.Combine(AppContext.BaseDirectory, "Filee.Engines.Tests.dll"); - - var ex = await Assert.ThrowsAsync(() => - engine.RecognizeAsync(imagePath, "xal", TestContext.Current.CancellationToken)); - Assert.Contains("xal", ex.Message); - } -} diff --git a/tests/Filee.Engines.Tests/VisionOcrTests.cs b/tests/Filee.Engines.Tests/VisionOcrTests.cs new file mode 100644 index 000000000..1e575e92f --- /dev/null +++ b/tests/Filee.Engines.Tests/VisionOcrTests.cs @@ -0,0 +1,457 @@ +// Text recognition with Apple Vision (VisionConverter): the language mapping, the invisible text layer it writes and +// the converter around a stand-in recognizer run everywhere; AppleVisionTests read real pictures on macOS only. + +using System.Collections.Concurrent; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Filee.Engines.Infrastructure; +using Filee.Engines.Ocr; +using ImageMagick; +using ImageMagick.Drawing; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; +using UglyToad.PdfPig.Core; + +namespace Filee.Engines.Tests; + +public class VisionLanguageTests +{ + private static readonly string[] Ventura = [.. VisionLanguages.Fallback]; + + [Fact] + public void A_language_vision_reads_is_asked_for_with_english_next_to_it() + { + Assert.Equal(["en-US"], VisionLanguages.For("eng", Ventura)); + Assert.Equal(["ko-KR", "en-US"], VisionLanguages.For("kor", Ventura)); + Assert.Equal(["zh-Hant", "en-US"], VisionLanguages.For("chi_tra", Ventura)); + Assert.Equal(["DE-de", "en-US"], VisionLanguages.For("deu", ["DE-de", "en-US"])); // as this Mac spells it + } + + [Fact] + public void A_language_this_mac_has_no_model_for_is_left_to_tesseract() + { + Assert.Null(VisionLanguages.For("hin", Ventura)); // no Devanagari in Vision + Assert.Null(VisionLanguages.For("tha", Ventura)); // Thai came with a later macOS + Assert.Equal(["th-TH", "en-US"], VisionLanguages.For("tha", [.. Ventura, "th-TH"])); + Assert.Equal(["no-NO", "en-US"], VisionLanguages.For("nor", [.. Ventura, "no-NO"])); + Assert.Equal(["fr-FR"], VisionLanguages.For("fra", ["fr-FR"])); + } + + [Fact] + public void Every_mapped_language_is_one_filee_offers() + { + foreach (var code in new[] { "eng", "deu", "fra", "spa", "por", "ita", "nld", "pol", "ces", "ron", "swe", "dan", "nor", "tur", + "ind", "msa", "rus", "ukr", "ara", "tha", "vie", "kor", "jpn", "chi_sim", "chi_tra" }) + Assert.NotNull(OcrLanguages.Find(code)); + } +} + +public class OcrTextLayerTests(EngineFixture fx) : IClassFixture +{ + [Fact] + public void Words_are_found_at_spaces_and_spread_over_the_line_when_they_have_no_boxes() + { + Assert.Equal([(0, 5), (7, 5), (13, 1)], OcrLine.WordRanges("Filee reads\tA ")); + var line = OcrLine.Estimate("ab cd", 0.1, 0.5, 0.5, 0.1); + Assert.Equal(["ab", "cd"], line.Words.Select(w => w.Text)); + Assert.Equal(0.1, line.Words[0].Left, 6); + Assert.Equal(0.2, line.Words[0].Width, 6); // 5 letters' width, the space included + Assert.Equal(0.4, line.Words[1].Left, 6); + Assert.Empty(OcrLine.Estimate(" ", 0, 0, 1, 1).Words); + } + + [Fact] + public void The_glyphless_font_is_a_well_formed_truetype_font() + { + var font = GlyphlessFont.Bytes; + Assert.Equal(0x00010000u, BigEndian(font, 0)); + uint sum = 0; + for (var i = 0; i < font.Length; i += 4) + sum = unchecked(sum + BigEndian(font, i)); + Assert.Equal(0xB1B0AFBAu, sum); // the whole font sums to the magic number (head.checkSumAdjustment) + var tags = Enumerable.Range(0, (font[4] << 8) | font[5]).Select(i => System.Text.Encoding.ASCII.GetString(font, 12 + 16 * i, 4)).ToList(); + Assert.Equal(tags.Order(StringComparer.Ordinal), tags); + Assert.Contains("head", tags); + Assert.Contains("hmtx", tags); + using var typeface = SkiaSharp.SKTypeface.FromData(SkiaSharp.SKData.CreateCopy(font)); + Assert.NotNull(typeface); // the system's font loader takes it + Assert.Equal(2, typeface.GlyphCount); + } + + private static uint BigEndian(byte[] bytes, int at) => + (uint)((at < bytes.Length ? bytes[at] : 0) << 24 | (at + 1 < bytes.Length ? bytes[at + 1] : 0) << 16 + | (at + 2 < bytes.Length ? bytes[at + 2] : 0) << 8 | (at + 3 < bytes.Length ? bytes[at + 3] : 0)); + + [Fact] + public void The_recognized_words_are_invisible_text_where_the_picture_shows_them() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "page.png", 1240, 1754); + var pdf = Path.Combine(folder, "page.pdf"); + using (var writer = new OcrPdfWriter()) + { + // An A4 page at 150 dpi: 595.2 × 841.92 points. + writer.AddPage(picture, 595.2, 841.92, + [ + new OcrLine(0.1, 0.8, 0.6, 0.02, [new OcrWord("Quarterly", 0.1, 0.2), new OcrWord("report", 0.35, 0.15)]), + OcrLine.Estimate("검색 가능한 문서 Документ", 0.1, 0.5, 0.7, 0.03), + ]); + writer.Save(pdf); + } + + using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); + var page = document.GetPage(1); + Assert.Equal(595.2, page.Width, 1); + Assert.Equal(841.92, page.Height, 1); + var words = page.GetWords().ToList(); + Assert.Equal(["Quarterly", "report", "검색", "가능한", "문서", "Документ"], words.Select(w => w.Text)); + Assert.All(page.Letters.Where(l => !string.IsNullOrWhiteSpace(l.Value)), l => Assert.Equal(TextRenderingMode.Neither, l.RenderingMode)); + var quarterly = words[0]; + Assert.Equal(0.1 * 595.2, quarterly.BoundingBox.Left, 0); + Assert.Equal(0.8 * 841.92, quarterly.Letters[0].StartBaseLine.Y, 0); + Assert.InRange(quarterly.BoundingBox.Right, 0.29 * 595.2, 0.35 * 595.2 + 1); // stretched up to the next word + Assert.InRange(words[1].BoundingBox.Left, 0.35 * 595.2 - 1, 0.35 * 595.2 + 1); + Assert.Single(page.GetImages()); + + // A page with an OCR layer counts as having text: OCR leaves it alone the next time. + Assert.Equal([true], TesseractConverter.PagesWithText(pdf)); + } + + [Fact] + public async Task A_page_with_the_layer_reads_into_txt() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "scan.png", 600, 800); + var pdf = Path.Combine(folder, "scan.pdf"); + using (var writer = new OcrPdfWriter()) + { + writer.AddPage(picture, 144, 192, [OcrLine.Estimate("The quarterly report is ready", 0.1, 0.7, 0.8, 0.04)]); + writer.Save(pdf); + } + + var job = await fx.ConvertAsync([pdf], new Preset { TargetFormat = "txt" }); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var text = await File.ReadAllTextAsync(job.Outputs.Single(), TestContext.Current.CancellationToken); + Assert.Contains("The quarterly report is ready", text); + } +} + +public class VisionConverterTests(EngineFixture fx) : IClassFixture +{ + private static Preset Ocr(string language = "eng") => new() { TargetFormat = "pdf", Pdf = { Ocr = true, OcrLanguage = language } }; + + private static ConversionStep Step(string input, string from, string output, Preset preset, Action? notice = null) => + new(input, from, "pdf", preset, new HwpxAssert.FixedPath(output), Path.GetDirectoryName(output)!, Notice: notice); + + private static string PageText(string pdf, int page) + { + using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); + return string.Join(" ", document.GetPage(page).GetWords().Select(w => w.Text)); + } + + [Fact] + public void Off_a_mac_it_is_unavailable_and_only_vision_takes_its_place_on_one() + { + var off = new VisionConverter(null, new VisionFakes.Tesseract(available: true)); + Assert.Equal("engine.reason.unsupported_platform", off.GetStatus().ReasonKey); + Assert.False(off.Reads("eng")); + + var mac = new VisionConverter(new VisionFakes.Recognizer(), new VisionFakes.Tesseract(available: true)); + Assert.True(mac.GetStatus().IsAvailable); + Assert.True(mac.Reads("kor")); + Assert.False(mac.Reads("hin")); + Assert.Equal(OperatingSystem.IsMacOS(), new VisionConverter().GetStatus().IsAvailable); + } + + [Fact] + public void On_a_mac_vision_takes_the_ocr_step_before_tesseract() + { + var all = EngineRegistry.CreateAll(new EngineEnvironment(fx.NewFolder())); + var ids = all.Select(c => c.Id).ToList(); + Assert.True(ids.IndexOf("vision") < ids.IndexOf("tesseract")); + + var vision = new VisionConverter(new VisionFakes.Recognizer(), new VisionFakes.Tesseract(available: true)); + var tesseract = new TesseractConverter(); + var catalog = new ConverterCatalog([vision, tesseract]) { Priority = new AppSettings().EnginePriority }; + var planner = new RoutePlanner(catalog.All, _ => 9); + + Assert.Equal("vision", planner.Plan("pdf", "pdf", ocr: true)!.Steps.Single().Converter.Id); + Assert.Equal("vision", planner.Plan("jpg", "pdf", ocr: true)!.Steps.Single().Converter.Id); + Assert.Equal("tesseract", new RoutePlanner([tesseract]).Plan("png", "pdf", ocr: true)!.Steps.Single().Converter.Id); + } + + [Fact] + public async Task A_picture_becomes_a_page_of_its_size_with_the_text_on_it() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "letter.jpg", 1200, 600, dpi: 150); + var recognizer = new VisionFakes.Recognizer(); + var output = Path.Combine(folder, "letter.pdf"); + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(picture, "jpg", output, Ocr("deu")), null, TestContext.Current.CancellationToken); + + Assert.Equal(["de-DE", "en-US"], Assert.Single(recognizer.Calls).Languages); + Assert.Equal(picture, recognizer.Calls.Single().Picture); // a JPEG goes in as it is + using var document = UglyToad.PdfPig.PdfDocument.Open(output); + Assert.Equal(576, document.GetPage(1).Width, 1); // 1200 px at 150 dpi = 8 inches + Assert.Equal(VisionFakes.Text, PageText(output, 1)); + } + + [Fact] + public async Task Every_page_of_a_tiff_is_recognized() + { + var folder = fx.NewFolder(); + var tiff = EngineFixture.MakeMultiPageTiff(folder, 3); + var recognizer = new VisionFakes.Recognizer(); + var output = Path.Combine(folder, "pages.pdf"); + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(tiff, "tiff", output, Ocr()), null, TestContext.Current.CancellationToken); + + Assert.Equal(3, recognizer.Calls.Count); + Assert.All(recognizer.Calls, c => Assert.EndsWith(".png", c.Picture)); + using var document = PdfReader.Open(output, PdfDocumentOpenMode.Import); + Assert.Equal(3, document.PageCount); + } + + [Fact] + public async Task Only_the_scanned_pages_of_a_pdf_are_recognized() + { + var folder = fx.NewFolder(); + var scan = (await fx.ConvertAsync([VisionFakes.Picture(folder, "scan.png", 600, 800)], new Preset { TargetFormat = "pdf" })).Outputs.Single(); + var searchable = Path.Combine(folder, "searchable.pdf"); + using (var writer = new OcrPdfWriter()) + { + writer.AddPage(VisionFakes.Picture(folder, "done.png", 600, 800), 144, 192, [OcrLine.Estimate("Already searchable", 0.1, 0.5, 0.8, 0.05)]); + writer.Save(searchable); + } + var mixed = Path.Combine(folder, "mixed.pdf"); + using (var document = new PdfDocument()) + { + foreach (var part in new[] { searchable, scan }) + { + using var source = PdfReader.Open(part, PdfDocumentOpenMode.Import); + document.AddPage(source.Pages[0]); + } + document.Save(mixed); + } + var recognizer = new VisionFakes.Recognizer(); + var output = Path.Combine(folder, "out.pdf"); + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(mixed, "pdf", output, Ocr()), null, TestContext.Current.CancellationToken); + + Assert.Single(recognizer.Calls); + Assert.Equal("Already searchable", PageText(output, 1)); + Assert.Equal(VisionFakes.Text, PageText(output, 2)); + using var result = UglyToad.PdfPig.PdfDocument.Open(output); + using var original = UglyToad.PdfPig.PdfDocument.Open(scan); + Assert.Equal(original.GetPage(1).Width, result.GetPage(2).Width, 0); // the page keeps its size + } + + [Fact] + public async Task A_pdf_with_text_on_every_page_is_copied_with_a_note() + { + var folder = fx.NewFolder(); + var searchable = Path.Combine(folder, "searchable.pdf"); + using (var writer = new OcrPdfWriter()) + { + writer.AddPage(VisionFakes.Picture(folder, "done.png", 600, 800), 144, 192, [OcrLine.Estimate("Already searchable", 0.1, 0.5, 0.8, 0.05)]); + writer.Save(searchable); + } + var recognizer = new VisionFakes.Recognizer(); + var output = Path.Combine(folder, "out.pdf"); + string? notice = null; + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(searchable, "pdf", output, Ocr(), n => notice = n), null, TestContext.Current.CancellationToken); + + Assert.Empty(recognizer.Calls); + Assert.Equal("notice.ocr_has_text", notice); + } + + [Fact] + public async Task A_language_vision_doesnt_read_goes_to_tesseract_or_asks_for_it() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "hindi.png", 300, 200); + var recognizer = new VisionFakes.Recognizer(); + var installed = new VisionFakes.Tesseract(available: true); + + var written = await new VisionConverter(recognizer, installed) + .ConvertAsync(Step(picture, "png", Path.Combine(folder, "a.pdf"), Ocr("hin")), null, TestContext.Current.CancellationToken); + + Assert.Equal(["tesseract"], written); + Assert.Equal(1, installed.Conversions); + Assert.Empty(recognizer.Calls); + + var refused = await Assert.ThrowsAsync(() => new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(picture, "png", Path.Combine(folder, "b.pdf"), Ocr("hin")), null, TestContext.Current.CancellationToken)); + Assert.Equal("error.ocr_needs_tesseract", refused.ErrorKey); + Assert.Equal(OcrLanguages.Find("hin")!.NativeName, refused.Detail); + } + + [Fact] + public void When_vision_refuses_two_languages_it_reads_the_first_alone() + { + var recognizer = new VisionFakes.Recognizer { FailWithSeveralLanguages = true }; + + var lines = VisionConverter.Recognize(recognizer, "page.png", ["ko-KR", "en-US"], TestContext.Current.CancellationToken); + + Assert.Single(lines); + Assert.Equal(2, recognizer.Calls.Count); + Assert.Equal(["ko-KR", "en-US"], recognizer.Calls[0].Languages); + Assert.Equal(["ko-KR"], recognizer.Calls[1].Languages); + } +} + +/// Apple Vision itself: only on macOS (CI's macOS jobs run these on Apple silicon and on Intel). +public class AppleVisionTests(EngineFixture fx) : IClassFixture +{ + private const string Skip = "Apple Vision runs on macOS only"; + + /// Black text on white at 300 dpi, as a scanner writes it, in a font macOS has. + private static string Scan(string folder, string name, string font, params string[] lines) + { + using var image = new MagickImage(MagickColors.White, 2480, 800) { Density = new Density(300) }; + var drawables = new Drawables().FontPointSize(32).FillColor(MagickColors.Black).Font(font); + for (var i = 0; i < lines.Length; i++) + drawables.Text(150, 240 + i * 200, lines[i]); + drawables.Draw(image); + var path = Path.Combine(folder, name); + image.Write(path); + return path; + } + + private const string Helvetica = "/System/Library/Fonts/Helvetica.ttc"; + private const string Gothic = "/System/Library/Fonts/AppleSDGothicNeo.ttc"; + + [Fact] + public void Vision_reads_the_lines_and_words_of_a_picture() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip(Skip); + return; + } + var vision = new AppleVision(); + Assert.True(vision.IsAvailable); + Assert.Contains("en-US", vision.Languages); + Assert.Contains("ko-KR", vision.Languages); + var scan = Scan(fx.NewFolder(), "letter.png", Helvetica, "Filee reads scanned pages", "into searchable documents"); + + var lines = vision.Recognize(scan, ["en-US"], TestContext.Current.CancellationToken); + + Assert.Equal(2, lines.Count); + var first = lines.Single(l => l.Words.Any(w => w.Text == "Filee")); + var second = lines.Single(l => l != first); + Assert.Equal(["Filee", "reads", "scanned", "pages"], first.Words.Select(w => w.Text)); + Assert.True(first.Bottom > second.Bottom); // measured from the bottom of the picture + foreach (var line in lines) + { + Assert.InRange(line.Height, 0.01, 0.2); + for (var i = 0; i < line.Words.Count; i++) + { + var word = line.Words[i]; + Assert.InRange(word.Left, line.Left - 0.01, line.Left + line.Width); + Assert.InRange(word.Width, 0.005, line.Width + 0.01); + if (i > 0) + Assert.True(word.Left > line.Words[i - 1].Left); + } + } + } + + [Fact] + public async Task A_picture_and_a_scanned_pdf_become_searchable_with_vision() + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), Skip); + var folder = fx.NewFolder(); + var picture = Scan(folder, "편지.png", Helvetica, "Filee reads scanned pages"); + var scanPdf = (await fx.ConvertAsync([Scan(folder, "Скан.png", Helvetica, "The quarterly report is ready")], new Preset { TargetFormat = "pdf" })).Outputs.Single(); + + var job = await fx.ConvertAsync([picture, scanPdf], new Preset { TargetFormat = "pdf", Pdf = { Ocr = true } }); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + Assert.Contains("scanned", Text(job.Outputs.Single(o => Path.GetFileName(o).StartsWith("편지", StringComparison.Ordinal)))); + Assert.Contains("quarterly", Text(job.Outputs.Single(o => Path.GetFileName(o).StartsWith("Скан", StringComparison.Ordinal)))); + } + + [Fact] + public async Task Korean_text_is_read_into_txt() + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), Skip); + var folder = fx.NewFolder(); + var scan = Scan(folder, "scan.png", Gothic, "분기 보고서가 준비되었습니다"); + + var job = await fx.ConvertAsync([scan], new Preset { TargetFormat = "txt", Pdf = { Ocr = true, OcrLanguage = "kor" } }); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var text = await File.ReadAllTextAsync(job.Outputs.Single(), TestContext.Current.CancellationToken); + Assert.Contains("보고서", text); + } + + private static string Text(string pdf) + { + using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); + return string.Join(" ", document.GetPages().SelectMany(p => p.GetWords()).Select(w => w.Text)); + } +} + +/// Stand-ins for Apple Vision and Tesseract. +internal static class VisionFakes +{ + public const string Text = "Filee reads scanned pages"; + + /// A grey picture of the given size and resolution. + public static string Picture(string folder, string name, uint width, uint height, int dpi = 300) + { + using var image = new MagickImage(MagickColors.LightGray, width, height) { Density = new Density(dpi) }; + var path = Path.Combine(folder, name); + image.Write(path); + return path; + } + + /// Finds on every picture and reads the languages Vision reads on macOS 13. + public sealed class Recognizer : ITextRecognizer + { + public ConcurrentQueue<(string Picture, IReadOnlyList Languages)> CallQueue { get; } = new(); + + public IReadOnlyList<(string Picture, IReadOnlyList Languages)> Calls => [.. CallQueue]; + + /// Fails like Vision on a combination of languages it doesn't take. + public bool FailWithSeveralLanguages { get; init; } + + public bool IsAvailable => true; + + public IReadOnlyCollection Languages => VisionLanguages.Fallback; + + public IReadOnlyList Recognize(string picture, IReadOnlyList languages, CancellationToken cancellationToken) + { + CallQueue.Enqueue((picture, languages)); + if (FailWithSeveralLanguages && languages.Count > 1) + throw new InvalidOperationException("Apple Vision couldn't read the picture."); + return [OcrLine.Estimate(Text, 0.1, 0.6, 0.8, 0.05)]; + } + } + + /// A Tesseract that is or isn't installed and only counts its conversions. + public sealed class Tesseract(bool available) : IConverter + { + public int Conversions { get; private set; } + public string Id => "tesseract"; + public string DisplayName => "Tesseract OCR"; + public int MaxParallelism => 1; + public IReadOnlyList Edges => []; + + public EngineStatus GetStatus() => available ? EngineStatus.Available() : EngineStatus.Unavailable("engine.reason.not_installed"); + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + { + Conversions++; + return Task.FromResult>(["tesseract"]); + } + } +} diff --git a/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj b/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj index 27f0ce7f1..a3d0e81a4 100644 --- a/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj +++ b/tests/Filee.Platform.Tests/Filee.Platform.Tests.csproj @@ -8,7 +8,6 @@ - diff --git a/tests/Filee.Platform.Tests/VisionOcrTests.cs b/tests/Filee.Platform.Tests/VisionOcrTests.cs deleted file mode 100644 index 2c05a3656..000000000 --- a/tests/Filee.Platform.Tests/VisionOcrTests.cs +++ /dev/null @@ -1,289 +0,0 @@ -// Tests for Apple Vision OCR engine and macOS OCR pipelines. - -using Filee.Core.Conversion; -using Filee.Core.Ocr; -using Filee.Core.Presets; -using Filee.Engines.Hwp.Hwpx; -using Filee.Engines.Ocr; -using Filee.Engines.Pdf; -using Filee.Platform.MacOS; - -namespace Filee.Platform.Tests; - -public sealed class VisionOcrTests -{ - private static string FindRepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Filee.slnx"))) - dir = dir.Parent; - return dir!.FullName; - } - - [Fact] - public async Task VisionOcrRecognizesTextInImageOnMacOS() - { - if (!OperatingSystem.IsMacOS()) - { - Assert.Skip("Requires macOS Vision framework."); - return; - } - - var engine = new MacOsVisionOcrEngine(); - Assert.True(engine.IsAvailable); - Assert.Equal("vision", engine.Id); - Assert.NotEmpty(engine.SupportedLanguages); - - var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); - Assert.True(File.Exists(imagePath), $"Test image {imagePath} should exist"); - - var result = await engine.RecognizeAsync(imagePath, "en-US", TestContext.Current.CancellationToken); - - Assert.NotEmpty(result.Text); - Assert.NotEmpty(result.Lines); - Assert.NotEmpty(result.Words); - Assert.True(result.ImageWidth > 0); - Assert.True(result.ImageHeight > 0); - - // The banner image has "Filee" - Assert.Contains("Filee", result.Text, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ImageOcrConverterConvertsImageToTextAndDocx() - { - if (!OperatingSystem.IsMacOS()) - { - Assert.Skip("Requires macOS Vision framework."); - return; - } - - var engine = new MacOsVisionOcrEngine(); - OcrEngineRegistry.Register(engine); - - var converter = new ImageOcrConverter(); - Assert.True(converter.GetStatus().IsAvailable); - - var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); - var workDir = Path.Combine(Path.GetTempPath(), $"filee-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(workDir); - - try - { - var ocrPreset = new Preset { Document = new DocumentOptions { Ocr = true } }; - var txtOut = Path.Combine(workDir, "banner.txt"); - var txtStep = new ConversionStep( - InputPath: imagePath, - From: "jpg", - To: "txt", - Preset: ocrPreset, - Output: new FixedPath(txtOut), - WorkDirectory: workDir); - - var txtOutputs = await converter.ConvertAsync(txtStep, null, TestContext.Current.CancellationToken); - Assert.Single(txtOutputs); - Assert.True(File.Exists(txtOutputs[0])); - var txt = await File.ReadAllTextAsync(txtOutputs[0], TestContext.Current.CancellationToken); - Assert.Contains("Filee", txt, StringComparison.OrdinalIgnoreCase); - - var docxOut = Path.Combine(workDir, "banner.docx"); - var docxStep = new ConversionStep( - InputPath: imagePath, - From: "jpg", - To: "docx", - Preset: ocrPreset, - Output: new FixedPath(docxOut), - WorkDirectory: workDir); - - var docxOutputs = await converter.ConvertAsync(docxStep, null, TestContext.Current.CancellationToken); - Assert.Single(docxOutputs); - Assert.True(File.Exists(docxOutputs[0])); - Assert.True(new FileInfo(docxOutputs[0]).Length > 1000); - } - finally - { - Directory.Delete(workDir, recursive: true); - } - } - - [Fact] - public void PdfDocumentReaderExtractsEditableTextFromScannedPdf() - { - if (!OperatingSystem.IsMacOS()) - { - Assert.Skip("Requires macOS Vision framework."); - return; - } - - var engine = new MacOsVisionOcrEngine(); - OcrEngineRegistry.Register(engine); - - var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); - var workDir = Path.Combine(Path.GetTempPath(), $"filee-pdf-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(workDir); - - try - { - var pdfPath = Path.Combine(workDir, "scanned.pdf"); - - // Create a pure scanned PDF with only a bitmap image using PdfSharp - using (var pdfDoc = new PdfSharp.Pdf.PdfDocument()) - { - var page = pdfDoc.AddPage(); - page.Width = PdfSharp.Drawing.XUnit.FromPoint(600); - page.Height = PdfSharp.Drawing.XUnit.FromPoint(400); - using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page); - using var img = PdfSharp.Drawing.XImage.FromFile(imagePath); - gfx.DrawImage(img, 0, 0, 600, 400); - pdfDoc.Save(pdfPath); - } - - Assert.True(File.Exists(pdfPath)); - - // Read the scanned PDF using PdfDocumentReader with OCR enabled - var mediaFolder = Path.Combine(workDir, "media"); - var document = PdfDocumentReader.Read(pdfPath, mediaFolder, engine, enableOcr: true, workDirectory: workDir); - - Assert.NotEmpty(document.Sections); - var paragraphs = document.Sections.SelectMany(s => s.Blocks).OfType().ToList(); - Assert.NotEmpty(paragraphs); - - var allText = string.Join(" ", paragraphs.SelectMany(p => p.Inlines).OfType().Select(t => t.Text)); - Assert.Contains("Filee", allText, StringComparison.OrdinalIgnoreCase); - - // Verify plain text reader also extracts the OCR text - var plainDoc = PdfDocumentReader.ReadText(pdfPath, engine, enableOcr: true, workDirectory: workDir); - var plainText = PdfDocumentReader.PlainText(plainDoc); - Assert.Contains("Filee", plainText, StringComparison.OrdinalIgnoreCase); - } - finally - { - Directory.Delete(workDir, recursive: true); - } - } - - [Fact] - public void ScannedPdfWithoutOcrStaysPictureOnly() - { - var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); - var workDir = Path.Combine(Path.GetTempPath(), $"filee-pdf-noocr-{Guid.NewGuid():N}"); - Directory.CreateDirectory(workDir); - - try - { - var pdfPath = Path.Combine(workDir, "scanned.pdf"); - using (var pdfDoc = new PdfSharp.Pdf.PdfDocument()) - { - var page = pdfDoc.AddPage(); - page.Width = PdfSharp.Drawing.XUnit.FromPoint(600); - page.Height = PdfSharp.Drawing.XUnit.FromPoint(400); - using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page); - using var img = PdfSharp.Drawing.XImage.FromFile(imagePath); - gfx.DrawImage(img, 0, 0, 600, 400); - pdfDoc.Save(pdfPath); - } - - var document = PdfDocumentReader.Read(pdfPath, Path.Combine(workDir, "media"), null, null, enableOcr: false); - var paragraphs = document.Sections.SelectMany(s => s.Blocks).OfType() - .SelectMany(p => p.Inlines).OfType().Select(t => t.Text).ToList(); - Assert.Empty(paragraphs); - } - finally - { - Directory.Delete(workDir, recursive: true); - } - } - - [Fact] - public async Task ImageOcrConverterSucceedsWithDefaultPresetWhenDocumentOcrIsFalse() - { - if (!OperatingSystem.IsMacOS()) - { - Assert.Skip("Requires macOS Vision framework."); - return; - } - - var engine = new MacOsVisionOcrEngine(); - OcrEngineRegistry.Register(engine); - - var converter = new ImageOcrConverter(); - var imagePath = Path.Combine(FindRepoRoot(), "docs", "media", "banner-en.jpg"); - var workDir = Path.Combine(Path.GetTempPath(), $"filee-test-default-preset-{Guid.NewGuid():N}"); - Directory.CreateDirectory(workDir); - - try - { - var defaultPreset = new Preset(); - Assert.False(defaultPreset.Document.Ocr); - - var txtOut = Path.Combine(workDir, "banner-default.txt"); - var txtStep = new ConversionStep( - InputPath: imagePath, - From: "jpg", - To: "txt", - Preset: defaultPreset, - Output: new FixedPath(txtOut), - WorkDirectory: workDir); - - var outputs = await converter.ConvertAsync(txtStep, null, TestContext.Current.CancellationToken); - Assert.Single(outputs); - Assert.True(File.Exists(outputs[0])); - var txt = await File.ReadAllTextAsync(outputs[0], TestContext.Current.CancellationToken); - Assert.Contains("Filee", txt, StringComparison.OrdinalIgnoreCase); - } - finally - { - Directory.Delete(workDir, recursive: true); - } - } - - [Fact] - public void PdfDocumentReaderDeletesTempScanInTextOnlyModeWhenOcrReturnsNoWords() - { - var emptyEngine = new EmptyMockEngine(); - var workDir = Path.Combine(Path.GetTempPath(), $"filee-pdf-leak-test-{Guid.NewGuid():N}"); - Directory.CreateDirectory(workDir); - - try - { - var pdfPath = Path.Combine(workDir, "blank-scanned.pdf"); - using (var pdfDoc = new PdfSharp.Pdf.PdfDocument()) - { - var page = pdfDoc.AddPage(); - page.Width = PdfSharp.Drawing.XUnit.FromPoint(200); - page.Height = PdfSharp.Drawing.XUnit.FromPoint(200); - using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page); - gfx.DrawRectangle(PdfSharp.Drawing.XBrushes.White, 0, 0, 200, 200); - pdfDoc.Save(pdfPath); - } - - var scanDir = Path.Combine(workDir, "pdf-scans"); - var plainDoc = PdfDocumentReader.ReadText(pdfPath, emptyEngine, enableOcr: true, workDirectory: workDir); - - if (Directory.Exists(scanDir)) - { - var leftoverFiles = Directory.GetFiles(scanDir, "*.png"); - Assert.Empty(leftoverFiles); - } - } - finally - { - Directory.Delete(workDir, recursive: true); - } - } - - private sealed class EmptyMockEngine : IOcrEngine - { - public string Id => "empty-mock"; - public string DisplayName => "Empty Mock"; - public bool IsAvailable => true; - public IReadOnlyList SupportedLanguages => ["en"]; - public Task RecognizeAsync(string imagePath, string? language = null, CancellationToken cancellationToken = default) => - Task.FromResult(OcrPageResult.Empty(100, 100)); - } - - private sealed class FixedPath(string path) : IOutputAllocator - { - public string? Allocate(string extension, string? suffix = null) => path; - } -}