From 464f78b5e5eaf2fecc5cc446be8db192512a8a46 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 6 Nov 2026 22:25:59 +0900 Subject: [PATCH 1/3] feat(ocr): let a preset read several languages Scans that mix languages (Simplified Chinese and English, say) read badly with one language. PdfOptions.OcrLanguage now holds one code or several joined with + as Tesseract takes them (chi_sim+eng), so older settings keep working and no migration is needed. OcrLanguages.Parse is the one reader: unlisted codes are left out, repeats count once, none left means English. Tesseract gets every language's data through the existing download, and a failed download names its language. Apple Vision asks for the languages in their order with English last; one it doesn't read sends them all to Tesseract, or names that language when Tesseract is missing. The preset editor keeps the main language list and adds "Other languages in the text". Fixes #1123 Co-Authored-By: Claude Opus 5.5 --- docs/ENGINES.md | 21 +++-- src/Filee.App/Assets/i18n/ar.json | 4 + src/Filee.App/Assets/i18n/de.json | 4 + src/Filee.App/Assets/i18n/en.json | 4 + src/Filee.App/Assets/i18n/es.json | 4 + src/Filee.App/Assets/i18n/fr.json | 4 + src/Filee.App/Assets/i18n/hi.json | 4 + src/Filee.App/Assets/i18n/id.json | 4 + src/Filee.App/Assets/i18n/it.json | 4 + src/Filee.App/Assets/i18n/ja.json | 4 + src/Filee.App/Assets/i18n/ko.json | 4 + src/Filee.App/Assets/i18n/ne.json | 4 + src/Filee.App/Assets/i18n/pl.json | 4 + src/Filee.App/Assets/i18n/pt-BR.json | 4 + src/Filee.App/Assets/i18n/pt-PT.json | 4 + src/Filee.App/Assets/i18n/ru.json | 4 + src/Filee.App/Assets/i18n/th.json | 4 + src/Filee.App/Assets/i18n/tr.json | 4 + src/Filee.App/Assets/i18n/vi.json | 4 + src/Filee.App/Assets/i18n/zh-CN.json | 4 + src/Filee.App/Assets/i18n/zh-TW.json | 4 + .../ViewModels/PresetEditorViewModel.cs | 54 +++++++++++- src/Filee.App/Views/PresetEditorView.axaml | 51 ++++++++--- src/Filee.Core/Presets/Preset.cs | 5 +- src/Filee.Engines/Ocr/OcrLanguages.cs | 19 +++- src/Filee.Engines/Ocr/TesseractConverter.cs | 53 +++++++---- src/Filee.Engines/Ocr/VisionConverter.cs | 30 ++++--- src/Filee.Engines/Ocr/VisionLanguages.cs | 37 +++++--- tests/Filee.App.Tests/RenderTests.cs | 59 +++++++++++++ tests/Filee.Engines.Tests/OcrTests.cs | 88 ++++++++++++++++++- tests/Filee.Engines.Tests/VisionOcrTests.cs | 53 +++++++++++ 31 files changed, 485 insertions(+), 65 deletions(-) diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 462aa69f..040c5d13 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -1873,6 +1873,14 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa scans and pictures with Tesseract (`Ocr/TesseractConverter.cs`), in the language the preset names (`PdfOptions.OcrLanguage`, a Tesseract code; English by default). The built-in "Searchable PDF" preset turns it on. +Text that mixes languages (issue #1123) has several codes in that one setting, the main language first, joined with +`+` as Tesseract's `-l` takes them (`chi_sim+eng`). `OcrLanguages.Parse` is the only reader: a code not in the list is +left out, repeats count once, and with none left English is read. Settings from before this hold one code and read as +they always did; an older Filee reads a joined value as a code it doesn't know, so English. Each language is its own +download and is fetched before the file is read; a download that fails refuses the file and names that language. Every +language makes Tesseract slower (it tries the others on words the first one reads badly), so the editor's "Other +languages in the text" says to add only what the text has. + - The route planner puts an OCR edge first (`ConversionEdge.Ocr`, `RoutePlanner.Plan(ocr: true)`): picture or PDF → searchable PDF, then the cheapest ordinary route from that PDF. A picture Tesseract can't open (HEIC, PSD, …) becomes a PNG first. OCR edges are never part of an ordinary route. @@ -1901,12 +1909,13 @@ level and the newest revision the Mac has), part of macOS, so nothing is downloa 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. +- The preset's languages (Tesseract codes) are mapped to Vision's identifiers (`VisionLanguages`) and asked for in their + order with English last, whether it was chosen or not; if Vision refuses the combination it reads with one language + fewer, down to the first 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, together with the preset's other languages, and is refused + otherwise with "Apple Vision can't read this language. Install Tesseract …" and its name. 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. diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 70462a94..106e4c39 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -900,6 +900,10 @@ "presets.ocr_hint_macos": "يصبح نص المستندات الممسوحة والصور قابلًا للبحث والتحرير بأداة التعرّف على النص المدمجة في macOS (Apple Vision). اللغات التي لا تقرؤها تتطلب Tesseract (الإعدادات ← المحركات).\nتبقى الصفحات التي تحتوي على نص كما هي.", "presets.ocr_language": "لغة النص", "presets.ocr_language_size": "تنزيل {0}", + "presets.ocr_more_languages": "لغات أخرى في النص", + "presets.ocr_add_language": "إضافة لغة", + "presets.ocr_remove_language": "إزالة هذه اللغة", + "presets.ocr_languages_hint": "أضف فقط اللغات الموجودة فعلًا في النص، فكل لغة تجعل التعرّف أبطأ.", "presets.pdf_keep_metadata": "الاحتفاظ بالبيانات الوصفية للصفحات والصور (XMP)", "presets.watermark": "علامة مائية", "presets.watermark_hint": "مثلًا: مسودة", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index fbb22811..9f9b83f8 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -900,6 +900,10 @@ "presets.ocr_hint_macos": "Scans und Bilder erhalten durchsuchbaren und bearbeitbaren Text mit der Texterkennung von macOS (Apple Vision). Nicht unterstützte Sprachen benötigen Tesseract (Einstellungen → Module).\nSeiten, die bereits Text enthalten, bleiben unverändert.", "presets.ocr_language": "Sprache des Textes", "presets.ocr_language_size": "{0} Download", + "presets.ocr_more_languages": "Weitere Sprachen im Text", + "presets.ocr_add_language": "Sprache hinzufügen", + "presets.ocr_remove_language": "Diese Sprache entfernen", + "presets.ocr_languages_hint": "Füge nur Sprachen hinzu, die wirklich im Text vorkommen: Jede weitere macht die Erkennung langsamer.", "presets.pdf_keep_metadata": "Seiten- und Bildmetadaten beibehalten (XMP)", "presets.watermark": "Wasserzeichen", "presets.watermark_hint": "z. B. ENTWURF", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 58fee226..4506d55a 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -915,6 +915,10 @@ "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.ocr_more_languages": "Other languages in the text", + "presets.ocr_add_language": "Add a language", + "presets.ocr_remove_language": "Remove this language", + "presets.ocr_languages_hint": "Only add languages the text really has: each one makes recognition slower.", "presets.pdf_keep_metadata": "Keep page and picture metadata (XMP)", "presets.watermark": "Watermark", "presets.watermark_hint": "e.g. DRAFT", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 26976121..67900973 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -900,6 +900,10 @@ "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.ocr_more_languages": "Otros idiomas del texto", + "presets.ocr_add_language": "Añadir un idioma", + "presets.ocr_remove_language": "Quitar este idioma", + "presets.ocr_languages_hint": "Añade solo los idiomas que de verdad tiene el texto: cada uno hace más lento el reconocimiento.", "presets.pdf_keep_metadata": "Conservar metadatos de página e imagen (XMP)", "presets.watermark": "Marca de agua", "presets.watermark_hint": "p. ej., BORRADOR", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 9eb2c2ce..7ee741ad 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -915,6 +915,10 @@ "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.ocr_more_languages": "Autres langues du texte", + "presets.ocr_add_language": "Ajouter une langue", + "presets.ocr_remove_language": "Retirer cette langue", + "presets.ocr_languages_hint": "N'ajoutez que les langues vraiment présentes dans le texte : chacune ralentit la reconnaissance.", "presets.pdf_keep_metadata": "Conserver les métadonnées des pages et des images (XMP)", "presets.watermark": "Filigrane", "presets.watermark_hint": "p. ex. BROUILLON", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 23d03cd2..4cb2649a 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -900,6 +900,10 @@ "presets.ocr_hint_macos": "macOS की अपनी टेक्स्ट पहचान (Apple Vision) से स्कैन और तस्वीरों का टेक्स्ट खोजने और बदलने योग्य बनता है। जो भाषाएँ यह नहीं पढ़ सकता, उनके लिए Tesseract चाहिए (सेटिंग्स → इंजन)।\nजिन पेजों में पहले से टेक्स्ट है वे वैसे ही रहते हैं।", "presets.ocr_language": "टेक्स्ट की भाषा", "presets.ocr_language_size": "{0} डाउनलोड", + "presets.ocr_more_languages": "टेक्स्ट की अन्य भाषाएँ", + "presets.ocr_add_language": "भाषा जोड़ें", + "presets.ocr_remove_language": "यह भाषा हटाएँ", + "presets.ocr_languages_hint": "सिर्फ़ वही भाषाएँ जोड़ें जो टेक्स्ट में सच में हैं: हर भाषा से पहचान धीमी होती है।", "presets.pdf_keep_metadata": "पेज और तस्वीर का मेटाडेटा (XMP) रखें", "presets.watermark": "वॉटरमार्क", "presets.watermark_hint": "जैसे: ड्राफ्ट", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index c3483ad7..924e98aa 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -900,6 +900,10 @@ "presets.ocr_hint_macos": "Hasil pindai dan gambar mendapatkan teks yang dapat dicari dan disalin dengan pengenal teks bawaan macOS (Apple Vision). Bahasa yang tidak didukungnya memerlukan Tesseract (Pengaturan → Engine).\nHalaman yang sudah memiliki teks dibiarkan apa adanya.", "presets.ocr_language": "Bahasa teks", "presets.ocr_language_size": "Unduhan {0}", + "presets.ocr_more_languages": "Bahasa lain dalam teks", + "presets.ocr_add_language": "Tambah bahasa", + "presets.ocr_remove_language": "Hapus bahasa ini", + "presets.ocr_languages_hint": "Tambahkan hanya bahasa yang benar-benar ada dalam teks: setiap bahasa membuat pengenalan lebih lambat.", "presets.pdf_keep_metadata": "Pertahankan metadata halaman dan gambar (XMP)", "presets.watermark": "Tanda air (watermark)", "presets.watermark_hint": "contoh: DRAF", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index bc868c7c..0f8b9b6a 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -899,6 +899,10 @@ "presets.ocr_hint_macos": "Scansioni e immagini ottengono testo ricercabile e modificabile, con il riconoscimento testo di macOS (Apple Vision). Le lingue che non riesce a leggere richiedono Tesseract (Impostazioni → Motori).\nLe pagine che hanno già testo restano come sono.", "presets.ocr_language": "Lingua del testo", "presets.ocr_language_size": "Download di {0}", +"presets.ocr_more_languages": "Altre lingue nel testo", +"presets.ocr_add_language": "Aggiungi una lingua", +"presets.ocr_remove_language": "Rimuovi questa lingua", +"presets.ocr_languages_hint": "Aggiungi solo le lingue davvero presenti nel testo: ognuna rallenta il riconoscimento.", "presets.pdf_keep_metadata": "Mantieni i metadati di pagine e immagini (XMP)", "presets.watermark": "Filigrana", "presets.watermark_hint": "ad es. BOZZA", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 02b3a0a5..dee386b8 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -900,6 +900,10 @@ "presets.ocr_hint_macos": "スキャンや画像の文字を、macOS 標準の文字認識(Apple Vision)で検索・編集できるようにします。読めない言語には Tesseract が必要です(「設定」→「変換エンジン」)。\nすでにテキストがあるページはそのままです。", "presets.ocr_language": "テキストの言語", "presets.ocr_language_size": "{0} をダウンロード", + "presets.ocr_more_languages": "テキストに含まれる他の言語", + "presets.ocr_add_language": "言語を追加", + "presets.ocr_remove_language": "この言語を削除", + "presets.ocr_languages_hint": "テキストに実際に含まれる言語だけを追加してください。言語が増えるほど認識に時間がかかります。", "presets.pdf_keep_metadata": "ページと画像のメタデータ(XMP)を保持", "presets.watermark": "透かし", "presets.watermark_hint": "例:下書き", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 07be3603..2ea55c8e 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -915,6 +915,10 @@ "presets.ocr_hint_macos": "스캔본과 사진의 글자를 macOS에 들어 있는 문자 인식(Apple Vision)으로 검색하고 편집할 수 있게 돼요. Apple Vision이 못 읽는 언어는 Tesseract가 필요해요(설정 → 변환 엔진).\n이미 텍스트가 있는 페이지는 그대로 둬요.", "presets.ocr_language": "텍스트 언어", "presets.ocr_language_size": "{0} 내려받기", + "presets.ocr_more_languages": "텍스트에 함께 있는 다른 언어", + "presets.ocr_add_language": "언어 추가", + "presets.ocr_remove_language": "이 언어 빼기", + "presets.ocr_languages_hint": "텍스트에 실제로 있는 언어만 추가해 주세요. 언어가 많을수록 인식이 느려져요.", "presets.pdf_keep_metadata": "페이지·이미지 메타데이터(XMP) 유지", "presets.watermark": "워터마크", "presets.watermark_hint": "예: 초안", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 6b27248e..1033e61b 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -900,6 +900,10 @@ "presets.ocr_hint_macos": "macOS को आफ्नै पाठ पहिचान (Apple Vision) ले स्क्यान र तस्बिरको पाठ खोज्न र सम्पादन गर्न मिल्ने हुन्छ। यसले पढ्न नसक्ने भाषाका लागि Tesseract चाहिन्छ (सेटिङहरू → इन्जिनहरू)।\nपहिले नै पाठ भएका पृष्ठ जस्ताको तस्तै रहन्छन्।", "presets.ocr_language": "पाठको भाषा", "presets.ocr_language_size": "{0} डाउनलोड", + "presets.ocr_more_languages": "पाठका अन्य भाषाहरू", + "presets.ocr_add_language": "भाषा थप्नुहोस्", + "presets.ocr_remove_language": "यो भाषा हटाउनुहोस्", + "presets.ocr_languages_hint": "पाठमा साँच्चै भएका भाषा मात्र थप्नुहोस्: हरेक भाषाले पहिचान ढिलो बनाउँछ।", "presets.pdf_keep_metadata": "पृष्ठ र तस्बिरको मेटाडाटा (XMP) राख्नुहोस्", "presets.watermark": "वाटरमार्क", "presets.watermark_hint": "जस्तै: मस्यौदा", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 64669a38..3533a766 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -915,6 +915,10 @@ "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.ocr_more_languages": "Inne języki w tekście", + "presets.ocr_add_language": "Dodaj język", + "presets.ocr_remove_language": "Usuń ten język", + "presets.ocr_languages_hint": "Dodawaj tylko języki, które naprawdę są w tekście: każdy kolejny spowalnia rozpoznawanie.", "presets.pdf_keep_metadata": "Zachowaj metadane stron i obrazów (XMP)", "presets.watermark": "Znak wodny", "presets.watermark_hint": "np. SZKIC", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 4845f368..8b7eab71 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -915,6 +915,10 @@ "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.ocr_more_languages": "Outros idiomas no texto", + "presets.ocr_add_language": "Adicionar um idioma", + "presets.ocr_remove_language": "Remover este idioma", + "presets.ocr_languages_hint": "Adicione só os idiomas que o texto realmente tem: cada um deixa o reconhecimento mais lento.", "presets.pdf_keep_metadata": "Manter metadados de página e imagem (XMP)", "presets.watermark": "Marca d'água", "presets.watermark_hint": "ex.: RASCUNHO", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 30bc2c7e..ba04e0fd 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -915,6 +915,10 @@ "presets.ocr_hint_macos": "As digitalizações e imagens passam a ter texto que podes pesquisar e editar, com o reconhecimento de texto do macOS (Apple Vision). Os idiomas que não consegue ler requerem o Tesseract (Definições → Motores).\nAs páginas que já têm texto mantêm-se.", "presets.ocr_language": "Idioma do texto", "presets.ocr_language_size": "Transferência de {0}", + "presets.ocr_more_languages": "Outros idiomas no texto", + "presets.ocr_add_language": "Adicionar um idioma", + "presets.ocr_remove_language": "Remover este idioma", + "presets.ocr_languages_hint": "Adiciona só os idiomas que o texto tem mesmo: cada um torna o reconhecimento mais lento.", "presets.pdf_keep_metadata": "Manter metadados de páginas e imagens (XMP)", "presets.watermark": "Marca de água", "presets.watermark_hint": "Por exemplo, RASCUNHO", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 1d1668bb..f1506f6a 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -915,6 +915,10 @@ "presets.ocr_hint_macos": "Текст сканов и изображений можно будет искать и редактировать с помощью встроенного в macOS распознавания текста (Apple Vision). Для языков, которые оно не читает, нужен Tesseract (Настройки → Движки).\nСтраницы, где уже есть текст, не меняются.", "presets.ocr_language": "Язык текста", "presets.ocr_language_size": "Загрузка {0}", + "presets.ocr_more_languages": "Другие языки в тексте", + "presets.ocr_add_language": "Добавить язык", + "presets.ocr_remove_language": "Убрать этот язык", + "presets.ocr_languages_hint": "Добавляйте только языки, которые действительно есть в тексте: каждый новый замедляет распознавание.", "presets.pdf_keep_metadata": "Сохранять метаданные страниц и картинок (XMP)", "presets.watermark": "Водяной знак", "presets.watermark_hint": "например, ЧЕРНОВИК", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 9d94199d..f07b33ac 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -900,6 +900,10 @@ "presets.ocr_hint_macos": "ข้อความในภาพสแกนและรูปภาพจะค้นหาและแก้ไขได้ด้วยระบบรู้จำข้อความในตัวของ macOS (Apple Vision) ภาษาที่อ่านไม่ได้ต้องใช้ Tesseract (การตั้งค่า → เครื่องมือแปลงไฟล์)\nหน้าที่มีข้อความอยู่แล้วจะคงไว้ตามเดิม", "presets.ocr_language": "ภาษาของข้อความ", "presets.ocr_language_size": "ดาวน์โหลด {0}", + "presets.ocr_more_languages": "ภาษาอื่นในข้อความ", + "presets.ocr_add_language": "เพิ่มภาษา", + "presets.ocr_remove_language": "เอาภาษานี้ออก", + "presets.ocr_languages_hint": "เพิ่มเฉพาะภาษาที่มีอยู่ในข้อความจริง ๆ เพราะทุกภาษาที่เพิ่มจะทำให้การรู้จำช้าลง", "presets.pdf_keep_metadata": "คงเมทาดาตาของหน้าและรูปภาพ (XMP)", "presets.watermark": "ลายน้ำ", "presets.watermark_hint": "เช่น ฉบับร่าง", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 657e796a..1f7868d9 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -900,6 +900,10 @@ "presets.ocr_hint_macos": "Tarama ve görsellere macOS'un kendi metin tanımasıyla (Apple Vision) aranabilir ve düzenlenebilir metin eklenir. Okuyamadığı diller için Tesseract gerekir (Ayarlar → Dönüştürme motorları).\nZaten metin içeren sayfalar olduğu gibi kalır.", "presets.ocr_language": "Metnin dili", "presets.ocr_language_size": "{0} indirme", + "presets.ocr_more_languages": "Metindeki diğer diller", + "presets.ocr_add_language": "Dil ekle", + "presets.ocr_remove_language": "Bu dili kaldır", + "presets.ocr_languages_hint": "Yalnızca metinde gerçekten bulunan dilleri ekleyin: her dil tanımayı yavaşlatır.", "presets.pdf_keep_metadata": "Sayfa ve görsel üst verisini koru (XMP)", "presets.watermark": "Filigran", "presets.watermark_hint": "ör. TASLAK", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 413ae81c..8324b706 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -915,6 +915,10 @@ "presets.ocr_hint_macos": "Bản quét và ảnh sẽ có văn bản để tìm kiếm và chỉnh sửa, bằng tính năng nhận dạng văn bản của chính macOS (Apple Vision). Ngôn ngữ mà macOS không đọc được cần Tesseract (Cài đặt → Bộ xử lý).\nTrang đã có văn bản được giữ nguyên.", "presets.ocr_language": "Ngôn ngữ của văn bản", "presets.ocr_language_size": "Tải xuống {0}", + "presets.ocr_more_languages": "Các ngôn ngữ khác trong văn bản", + "presets.ocr_add_language": "Thêm ngôn ngữ", + "presets.ocr_remove_language": "Xóa ngôn ngữ này", + "presets.ocr_languages_hint": "Chỉ thêm các ngôn ngữ thực sự có trong văn bản: mỗi ngôn ngữ đều làm việc nhận dạng chậm hơn.", "presets.pdf_keep_metadata": "Giữ siêu dữ liệu của trang và ảnh (XMP)", "presets.watermark": "Hình mờ", "presets.watermark_hint": "ví dụ BẢN NHÁP", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index d481b776..de8cf312 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -915,6 +915,10 @@ "presets.ocr_hint_macos": "使用 macOS 自带的文字识别(Apple Vision),让扫描件和图片中的文字可以搜索和编辑。它无法识别的语言需要 Tesseract(设置 → 转换引擎)。\n已有文字的页面保持不变。", "presets.ocr_language": "文字的语言", "presets.ocr_language_size": "下载 {0}", + "presets.ocr_more_languages": "文字中的其他语言", + "presets.ocr_add_language": "添加语言", + "presets.ocr_remove_language": "移除此语言", + "presets.ocr_languages_hint": "只添加文字中确实有的语言。每多一种语言,识别都会变慢。", "presets.pdf_keep_metadata": "保留页面和图片元数据 (XMP)", "presets.watermark": "水印", "presets.watermark_hint": "例如:草稿", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index e7ff8e73..06d7b7c0 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -915,6 +915,10 @@ "presets.ocr_hint_macos": "使用 macOS 內建的文字辨識(Apple Vision),讓掃描檔和圖片中的文字可以搜尋和編輯。它無法辨識的語言需要 Tesseract(設定 → 轉換引擎)。\n已有文字的頁面保持不變。", "presets.ocr_language": "文字的語言", "presets.ocr_language_size": "下載 {0}", + "presets.ocr_more_languages": "文字中的其他語言", + "presets.ocr_add_language": "新增語言", + "presets.ocr_remove_language": "移除此語言", + "presets.ocr_languages_hint": "只新增文字中確實有的語言。每多一種語言,辨識都會變慢。", "presets.pdf_keep_metadata": "保留頁面和圖片中繼資料 (XMP)", "presets.watermark": "浮水印", "presets.watermark_hint": "例如:草稿", diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index edec844d..4273d024 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -3,6 +3,7 @@ // // Works on a clone; Apply() copies the edits back into the original preset (Cancel = just drop the VM). +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Filee.App.Services; @@ -182,7 +183,11 @@ .. WhisperLanguages.All _pageNumbers = _edit.Pdf.PageNumbers; _pdfPassword = _edit.Pdf.Password; _ocr = _edit.Pdf.Ocr; - _ocrLanguage = OcrLanguageChoices.FirstOrDefault(l => l.Value == _edit.Pdf.OcrLanguage) ?? OcrLanguageChoices[0]; + var ocrLanguages = OcrLanguages.Parse(_edit.Pdf.OcrLanguage).Select(l => OcrLanguageChoices.First(c => c.Value == l.Code)).ToList(); + _ocrLanguage = ocrLanguages[0]; + MoreOcrLanguages = [.. ocrLanguages.Skip(1)]; + MoreOcrLanguages.CollectionChanged += (_, _) => RefreshOcrLanguagesToAdd(); + RefreshOcrLanguagesToAdd(); _location = _edit.Output.AskEveryTime ? Locations[^1] : Locations.First(l => l.Value == _edit.Output.Location); _conflict = Conflicts.First(c => c.Value == _edit.Output.Conflict); _tiffCompression = TiffCompressions.First(t => t.Value == _edit.Image.TiffCompression); @@ -319,6 +324,13 @@ .. WhisperLanguages.All /// Languages Tesseract reads (PdfOptions.OcrLanguage), English first. public IReadOnlyList> OcrLanguageChoices { get; } + + /// Languages read next to , for text that mixes them (#1123); PdfOptions.OcrLanguage holds them all. + public ObservableCollection> MoreOcrLanguages { get; } + + /// The languages not chosen yet, for the "add a language" list. + public ObservableCollection> OcrLanguagesToAdd { get; } = []; + /// The spoken languages Whisper recognizes (MediaOptions.SpokenLanguage), Auto first. public IReadOnlyList> SpokenLanguageChoices { get; } @@ -715,6 +727,43 @@ partial void OnPdfPasswordChanged(string value) private bool Recognizes => ShowOcr && Ocr; [ObservableProperty] private Choice _ocrLanguage; + /// Picked in the "add a language" list, until adds it. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(AddOcrLanguageCommand))] + private Choice? _ocrLanguageToAdd; + + public bool HasMoreOcrLanguages => MoreOcrLanguages.Count > 0; + + [RelayCommand(CanExecute = nameof(CanAddOcrLanguage))] + private void AddOcrLanguage() + { + if (OcrLanguageToAdd is not { } language) + return; + OcrLanguageToAdd = null; + MoreOcrLanguages.Add(language); + } + + private bool CanAddOcrLanguage() => OcrLanguageToAdd is not null; + + [RelayCommand] + private void RemoveOcrLanguage(Choice language) => MoreOcrLanguages.Remove(language); + + partial void OnOcrLanguageChanged(Choice? oldValue, Choice newValue) + { + if (Choices.Restore(oldValue, newValue, v => OcrLanguage = v)) + return; + MoreOcrLanguages.Remove(newValue); // it is the main one now + RefreshOcrLanguagesToAdd(); + } + + private void RefreshOcrLanguagesToAdd() + { + OcrLanguagesToAdd.Clear(); + foreach (var language in OcrLanguageChoices.Where(l => l != OcrLanguage && !MoreOcrLanguages.Contains(l))) + OcrLanguagesToAdd.Add(language); + OnPropertyChanged(nameof(HasMoreOcrLanguages)); + } + /// PDF → image: one tall image of all pages. [ObservableProperty] private bool _combinePages; [ObservableProperty] private bool _trimPageMargins; @@ -1109,7 +1158,8 @@ public bool Apply() pdf.PasswordLeftOut = false; // Only where the option is shown, as with SaveEveryFrame below. pdf.Ocr = Recognizes; - pdf.OcrLanguage = OcrLanguage.Value; + AddOcrLanguage(); // picked in the list without pressing Add: it looks chosen, so it counts + pdf.OcrLanguage = OcrLanguages.Join([OcrLanguage.Value, .. MoreOcrLanguages.Select(l => l.Value)]); if (pdf.Ocr) { // Hidden while text is recognized, so nothing is kept behind it that the PDF wouldn't do (Recognizes). diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 3ec4d5c4..cfff1b9d 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -25,6 +25,15 @@ + + + + + + + + + @@ -499,18 +508,40 @@ - - - - - - - - - + HorizontalContentAlignment="Stretch" MaxDropDownHeight="420" AutomationProperties.Name="{DynamicResource presets.ocr_language}" + ItemTemplate="{StaticResource OcrLanguageItem}" /> + + + + + + + + + + + + + + + + + + + + diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index be830f7c..7647ff87 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -1247,7 +1247,10 @@ public sealed class PdfOptions /// public bool Ocr { get; set; } - /// With : the language of the text, as a Tesseract code (eng, deu, chi_sim). + /// + /// With : the language of the text as a Tesseract code (eng, deu, chi_sim); text + /// that mixes languages has several, the main one first, joined with + as Tesseract takes them (chi_sim+eng). + /// public string OcrLanguage { get; set; } = "eng"; public PdfOptions Clone() => (PdfOptions)MemberwiseClone(); diff --git a/src/Filee.Engines/Ocr/OcrLanguages.cs b/src/Filee.Engines/Ocr/OcrLanguages.cs index 297ac8f3..b9032d2c 100644 --- a/src/Filee.Engines/Ocr/OcrLanguages.cs +++ b/src/Filee.Engines/Ocr/OcrLanguages.cs @@ -1,7 +1,8 @@ // The languages Tesseract can read text in (tessdata_fast 4.1.0, Apache-2.0), pinned in ocr-languages.json like the // engines in engines.json. A language is downloaded the first time a preset needs it, checked against its SHA-256 and // kept in engines/ocr-languages//.traineddata by EngineInstaller, so a damaged or partial download never -// counts as installed. +// counts as installed. A preset can name several languages for text that mixes them (#1123), joined with + in +// PdfOptions.OcrLanguage, which is how Tesseract takes them too (chi_sim+eng); each one is its own download. using System.Globalization; using System.Text.Json; @@ -51,6 +52,22 @@ public static class OcrLanguages public static OcrLanguage? Find(string code) => All.FirstOrDefault(l => l.Code == code); + /// + /// The languages a preset names (PdfOptions.OcrLanguage: one code, or several joined with +), the main one + /// first and each once. A code that isn't in the list is left out, and with none left is read. + /// + public static IReadOnlyList Parse(string? codes) + { + var languages = (codes ?? "").Split(Separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Find).OfType().Distinct().ToList(); + return languages.Count > 0 ? languages : [Find(Default)!]; + } + + /// Codes as PdfOptions.OcrLanguage keeps them and Tesseract's -l takes them, each once. + public static string Join(IEnumerable codes) => string.Join(Separator, codes.Distinct()); + + private const char Separator = '+'; + /// The language data, or null while it isn't downloaded. public static string? FileOf(string code) => EngineEnvironment.FirstExisting(Path.Combine(Root, code, code + ".traineddata")) is { } file diff --git a/src/Filee.Engines/Ocr/TesseractConverter.cs b/src/Filee.Engines/Ocr/TesseractConverter.cs index 58b5d876..c4dc3247 100644 --- a/src/Filee.Engines/Ocr/TesseractConverter.cs +++ b/src/Filee.Engines/Ocr/TesseractConverter.cs @@ -8,7 +8,8 @@ // without any are rendered at 300 dpi and recognized, page by page, at most four at a time. A small picture (a // screenshot) is read from a copy at twice its size, as its letters are too small for Tesseract (#138); the page still // shows the picture itself, with the words in their places on it. The language data comes from OcrLanguages, -// downloaded the first time a language is used, and is copied into the job's folder so Tesseract reads only it. +// downloaded the first time a language is used, and is copied into the job's folder so Tesseract reads only it. A +// preset can name several languages (#1123): each one's data is there, and Tesseract gets them all (-l chi_sim+eng). using Filee.Core.Conversion; using Filee.Core.Presets; @@ -83,22 +84,15 @@ public EngineStatus GetStatus() public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { var tesseract = _executable ?? Locate() ?? throw new InvalidOperationException("Tesseract is not installed."); - var language = OcrLanguages.Find(step.Preset.Pdf.OcrLanguage) ?? OcrLanguages.Find(OcrLanguages.Default)!; - string data; - try - { - data = await OcrLanguages.EnsureAsync(language.Code, cancellationToken); - } - catch (HttpRequestException ex) - { - throw new ConversionRefusedException("error.ocr_language_download", $"{language.NativeName}: {ex.Message}"); - } + var languages = OcrLanguages.Parse(step.Preset.Pdf.OcrLanguage); + var data = await LanguageDataAsync(languages, OcrLanguages.EnsureAsync, cancellationToken); var scratch = Path.Combine(step.WorkDirectory, "ocr-" + Guid.NewGuid().ToString("N")[..8]); var tessdata = Path.Combine(scratch, "tessdata"); Directory.CreateDirectory(tessdata); - File.Copy(data, Path.Combine(tessdata, Path.GetFileName(data))); + foreach (var file in data) + File.Copy(file, Path.Combine(tessdata, Path.GetFileName(file))); CopyPdfFont(tessdata, PdfFontFolders(tesseract, System.Environment.GetEnvironmentVariable("TESSDATA_PREFIX"))); - var ocr = new Run(tesseract, scratch, language.Code); + var ocr = new Run(tesseract, scratch, OcrLanguages.Join(languages.Select(l => l.Code))); progress?.Report(0.05); var output = step.Output.Allocate("pdf"); @@ -130,6 +124,28 @@ await RecognizePdfAsync(step, scratch, output, Runs, (picture, page, token) => o return [output]; } + /// + /// The data file of each language, one after the other as downloads what is missing + /// (). A download that fails refuses the file and says which language it was. + /// + internal static async Task> LanguageDataAsync(IReadOnlyList languages, + Func> ensure, CancellationToken cancellationToken) + { + var data = new List(); + foreach (var language in languages) + { + try + { + data.Add(await ensure(language.Code, cancellationToken)); + } + catch (HttpRequestException ex) + { + throw new ConversionRefusedException("error.ocr_language_download", $"{language.NativeName}: {ex.Message}"); + } + } + return data; + } + /// /// Tesseract 4.x (4.0.0 to 4.1.3; Ubuntu 20.04 and 22.04 ship 4.1.1) has no built-in copy of the invisible-text font /// of its PDF output: it reads pdf.ttf from the tessdata folder it is given (tessdata//pdf.ttf), and @@ -492,11 +508,12 @@ internal static void PutTextOnPicture(string textPdf, string picture, double wid } /// - /// One language, one Tesseract, working in (the job's scratch folder, with the language in - /// its tessdata). Tesseract only ever sees plain relative names there: some Windows builds of Tesseract and - /// Leptonica open files through the ANSI code page and fail on a user name or folder in Korean or Cyrillic. + /// One Tesseract reading (joined as its -l takes them), working in + /// (the job's scratch folder, with their data in its tessdata). Tesseract only ever + /// sees plain relative names there: some Windows builds of Tesseract and Leptonica open files through the ANSI code + /// page and fail on a user name or folder in Korean or Cyrillic. /// - private sealed record Run(string Executable, string Folder, string Language) + private sealed record Run(string Executable, string Folder, string Languages) { /// /// Reads and writes {outputBase}.pdf, both names in ; with @@ -505,7 +522,7 @@ private sealed record Run(string Executable, string Folder, string Language) public async Task ToPdfAsync(string input, string outputBase, int? dpi, CancellationToken cancellationToken, bool textOnly = false) { // tessedit_create_pdf instead of the "pdf" config file, which lives in Tesseract's own tessdata folder. - List args = [input, outputBase, "--tessdata-dir", "tessdata", "-l", Language, "-c", "tessedit_create_pdf=1"]; + List args = [input, outputBase, "--tessdata-dir", "tessdata", "-l", Languages, "-c", "tessedit_create_pdf=1"]; if (textOnly) args.AddRange(["-c", "textonly_pdf=1"]); if (dpi is { } value) diff --git a/src/Filee.Engines/Ocr/VisionConverter.cs b/src/Filee.Engines/Ocr/VisionConverter.cs index 6d43603e..567645bf 100644 --- a/src/Filee.Engines/Ocr/VisionConverter.cs +++ b/src/Filee.Engines/Ocr/VisionConverter.cs @@ -2,7 +2,8 @@ // 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. +// that is installed, and is refused with a hint to install it otherwise. With several languages in a preset, one that +// Vision doesn't read sends them all there: one recognizer reads the page. 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). @@ -65,12 +66,13 @@ public async Task> ConvertAsync(ConversionStep step, IProg { 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) + var chosen = OcrLanguages.Parse(step.Preset.Pdf.OcrLanguage); + if (VisionLanguages.For([.. chosen.Select(l => l.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); + throw new ConversionRefusedException("error.ocr_needs_tesseract", + string.Join(", ", chosen.Where(l => VisionLanguages.For(l.Code, recognizer.Languages) is null).Select(l => l.NativeName))); } var scratch = Path.Combine(step.WorkDirectory, "vision-" + Guid.NewGuid().ToString("N")[..8]); @@ -110,20 +112,24 @@ await Task.Run(() => } /// - /// Reads one picture. When Vision fails with a second language (English) next to the one asked for, it reads again - /// with that one alone. + /// Reads one picture. When Vision fails on a combination of languages it reads again without the last one (English, + /// which VisionLanguages adds, goes first), down to the first language alone. /// internal static IReadOnlyList Recognize(ITextRecognizer recognizer, string picture, IReadOnlyList languages, CancellationToken cancellationToken) { - try + for (var count = languages.Count; count > 1; count--) { - return recognizer.Recognize(picture, languages, cancellationToken); - } - catch (InvalidOperationException) when (languages.Count > 1 && !cancellationToken.IsCancellationRequested) - { - return recognizer.Recognize(picture, [languages[0]], cancellationToken); + try + { + return recognizer.Recognize(picture, [.. languages.Take(count)], cancellationToken); + } + catch (InvalidOperationException) when (!cancellationToken.IsCancellationRequested) + { + // Next: one language fewer. + } } + return recognizer.Recognize(picture, [languages[0]], cancellationToken); } /// A picture's pages: where each is, its size in pixels and its resolution. diff --git a/src/Filee.Engines/Ocr/VisionLanguages.cs b/src/Filee.Engines/Ocr/VisionLanguages.cs index 98c562c2..4aa68966 100644 --- a/src/Filee.Engines/Ocr/VisionLanguages.cs +++ b/src/Filee.Engines/Ocr/VisionLanguages.cs @@ -1,4 +1,4 @@ -// Which languages of the preset (PdfOptions.OcrLanguage, a Tesseract code from OcrLanguages) Apple Vision reads, as +// Which languages of the preset (PdfOptions.OcrLanguage, Tesseract codes 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). @@ -40,20 +40,33 @@ internal static class VisionLanguages 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 ; see the other overload. + public static IReadOnlyList? For(string code, IReadOnlyCollection supported) => For([code], supported); + /// - /// 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. + /// The languages to ask Vision for when the text is in : those languages in their order, then + /// English, always last (scans in other languages often have English words in them), or null when Vision doesn't + /// read one of them. /// /// What this Mac's Vision reads (). - public static IReadOnlyList? For(string code, IReadOnlyCollection supported) + public static IReadOnlyList? For(IReadOnlyList codes, 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]; + var languages = new List(); + foreach (var code in codes) + { + 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; + languages.Add(match); + } + if (supported.FirstOrDefault(s => string.Equals(s, "en-US", StringComparison.OrdinalIgnoreCase)) is { } english) + { + languages.Remove(english); + languages.Add(english); + } + return languages; } } diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index 5cbc2643..7a66e711 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -1509,6 +1509,65 @@ public void Preset_editor_shows_and_saves_text_recognition(string language) Assert.False(preset.Pdf.Ocr); } + [AvaloniaTheory] + [InlineData("en")] + [InlineData("zh-CN")] + public void Preset_editor_reads_and_saves_several_text_recognition_languages(string language) + { + TestServices.EnsureInitialized(language); + var loc = AppHost.Get(); + var preset = Filee.Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "searchable-pdf"); + preset.Pdf.OcrLanguage = "chi_sim+eng"; + var vm = new PresetEditorViewModel(preset, loc); + Assert.Equal("chi_sim", vm.OcrLanguage.Value); + Assert.Equal(["eng"], vm.MoreOcrLanguages.Select(l => l.Value)); + Assert.True(vm.HasMoreOcrLanguages); + Assert.DoesNotContain(vm.OcrLanguagesToAdd, l => l.Value is "chi_sim" or "eng"); + Assert.False(vm.AddOcrLanguageCommand.CanExecute(null)); + + var window = new PresetEditorWindow { DataContext = vm, Height = 1400 }; + window.Show(); + Pump(); + Save(window, $"preset-ocr-languages-{language}.png"); + window.Close(); + + // Unchanged, it is saved as it was. + Assert.True(vm.Apply()); + Assert.Equal("chi_sim+eng", preset.Pdf.OcrLanguage); + + // Added languages leave the list; one picked without pressing Add counts too. + vm.OcrLanguageToAdd = vm.OcrLanguagesToAdd.Single(l => l.Value == "deu"); + Assert.True(vm.AddOcrLanguageCommand.CanExecute(null)); + vm.AddOcrLanguageCommand.Execute(null); + Assert.Null(vm.OcrLanguageToAdd); + Assert.Equal(["eng", "deu"], vm.MoreOcrLanguages.Select(l => l.Value)); + Assert.DoesNotContain(vm.OcrLanguagesToAdd, l => l.Value == "deu"); + vm.OcrLanguageToAdd = vm.OcrLanguagesToAdd.Single(l => l.Value == "fra"); + Assert.True(vm.Apply()); + Assert.Equal("chi_sim+eng+deu+fra", preset.Pdf.OcrLanguage); + + // Made the main language, an added one isn't listed twice, and the old main one can be added again. + vm.OcrLanguage = vm.OcrLanguageChoices.Single(l => l.Value == "deu"); + Assert.Equal(["eng", "fra"], vm.MoreOcrLanguages.Select(l => l.Value)); + Assert.Contains(vm.OcrLanguagesToAdd, l => l.Value == "chi_sim"); + + // With the others removed, one code is left, as older settings have it. + vm.RemoveOcrLanguageCommand.Execute(vm.MoreOcrLanguages[0]); + vm.RemoveOcrLanguageCommand.Execute(vm.MoreOcrLanguages[0]); + Assert.False(vm.HasMoreOcrLanguages); + Assert.Contains(vm.OcrLanguagesToAdd, l => l.Value == "eng"); + Assert.True(vm.Apply()); + Assert.Equal("deu", preset.Pdf.OcrLanguage); + + vm = new PresetEditorViewModel(preset, loc); + Assert.Equal("deu", vm.OcrLanguage.Value); + Assert.Empty(vm.MoreOcrLanguages); + + // A code Filee doesn't list, in a preset from elsewhere, is left out. + preset.Pdf.OcrLanguage = "klingon"; + Assert.Equal("eng", new PresetEditorViewModel(preset, loc).OcrLanguage.Value); + } + [AvaloniaTheory] [InlineData("en")] [InlineData("ko")] diff --git a/tests/Filee.Engines.Tests/OcrTests.cs b/tests/Filee.Engines.Tests/OcrTests.cs index 9d2626b6..923cc352 100644 --- a/tests/Filee.Engines.Tests/OcrTests.cs +++ b/tests/Filee.Engines.Tests/OcrTests.cs @@ -34,6 +34,50 @@ public void Every_language_is_pinned_and_named() Assert.Null(OcrLanguages.Find("klingon")); } + private static IEnumerable Codes(string? setting) => OcrLanguages.Parse(setting).Select(l => l.Code); + + [Fact] + public void A_preset_names_one_language_or_several_joined_with_a_plus() + { + Assert.Equal(["deu"], Codes("deu")); // as settings have it from before several languages (#1123) + Assert.Equal(["chi_sim", "eng"], Codes("chi_sim+eng")); + Assert.Equal(["eng", "chi_sim"], Codes("eng+chi_sim")); // the main one stays first + Assert.Equal("chi_sim+eng", OcrLanguages.Join(["chi_sim", "eng", "chi_sim"])); + Assert.Equal("deu", OcrLanguages.Join(["deu"])); + } + + [Theory] + [InlineData("deu+klingon+deu", "deu")] + [InlineData("klingon", "eng")] + [InlineData("../../evil+eng", "eng")] + [InlineData("+", "eng")] + [InlineData("", "eng")] + [InlineData(null, "eng")] + public void Only_listed_languages_count_each_once_and_with_none_english_is_read(string? setting, string read) => + Assert.Equal([read], Codes(setting)); + + [Fact] + public async Task Each_language_is_fetched_and_a_failed_download_says_which_one() + { + var languages = OcrLanguages.Parse("chi_sim+deu+eng"); + var asked = new List(); + + var data = await TesseractConverter.LanguageDataAsync(languages, (code, _) => + { + asked.Add(code); + return Task.FromResult(code + ".traineddata"); + }, TestContext.Current.CancellationToken); + + Assert.Equal(["chi_sim", "deu", "eng"], asked); + Assert.Equal(["chi_sim.traineddata", "deu.traineddata", "eng.traineddata"], data); + + var refused = await Assert.ThrowsAsync(() => TesseractConverter.LanguageDataAsync(languages, (code, _) => + code == "deu" ? throw new HttpRequestException("no network") : Task.FromResult(code + ".traineddata"), TestContext.Current.CancellationToken)); + + Assert.Equal("error.ocr_language_download", refused.ErrorKey); + Assert.Equal("Deutsch: no network", refused.Detail); + } + [Fact] public void Tesseract_is_offered_but_not_suggested() { @@ -215,13 +259,18 @@ public class OcrTests(EngineFixture fx) : IClassFixture 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"); + private static readonly string? ChineseFont = EngineEnvironment.FirstExisting(@"C:\Windows\Fonts\msyh.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"); + /// A page as a scanner writes it: black text on white at 300 dpi, no text layer. - private static string Scan(string folder, string name, params string[] lines) + private static string Scan(string folder, string name, params string[] lines) => ScanIn(Arial, folder, name, lines); + + private static string ScanIn(string? font, string folder, string name, params string[] lines) { using var image = new MagickImage(MagickColors.White, 2480, 800) { Density = new Density(300) }; var drawables = new Drawables().FontPointSize(28).FillColor(MagickColors.Black); - if (Arial is not null) - drawables.Font(Arial); + if (font is not null) + drawables.Font(font); for (var i = 0; i < lines.Length; i++) drawables.Text(150, 220 + i * 180, lines[i]); drawables.Draw(image); @@ -460,4 +509,37 @@ public async Task Another_language_is_downloaded_when_a_preset_needs_it() Assert.Contains("Größe", text); Assert.Contains("Straße", text); } + + [Fact] + public async Task Text_in_two_scripts_is_read_only_with_both_languages() + { + Assert.SkipUnless(Installed, Skip); + var scan = Scan(fx.NewFolder(), "report.png", "Отчёт за квартал готов", "The quarterly report is ready"); + + var english = await fx.ConvertAsync([scan], Ocr("txt")); + var both = await fx.ConvertAsync([scan], Ocr("txt", "rus+eng")); + + AssertDone(english); + AssertDone(both); + Assert.DoesNotContain("квартал", await File.ReadAllTextAsync(english.Outputs.Single(), TestContext.Current.CancellationToken)); + Assert.True(OcrLanguages.IsInstalled("rus")); + var text = await File.ReadAllTextAsync(both.Outputs.Single(), TestContext.Current.CancellationToken); + Assert.Contains("квартал", text); + Assert.Contains("quarterly", text); + } + + [Fact] + public async Task Simplified_chinese_and_english_on_one_page_are_both_read() + { + // The scans of #1123. Linux runs without a Chinese font skip this; rus+eng above runs everywhere. + Assert.SkipUnless(Installed && ChineseFont is not null, Skip); + var scan = ScanIn(ChineseFont, fx.NewFolder(), "report.png", "季度报告已经准备好", "The quarterly report is ready"); + + var job = await fx.ConvertAsync([scan], Ocr("txt", "chi_sim+eng")); + + AssertDone(job); + var text = await File.ReadAllTextAsync(job.Outputs.Single(), TestContext.Current.CancellationToken); + Assert.Contains("报告", text); + Assert.Contains("quarterly", text); + } } diff --git a/tests/Filee.Engines.Tests/VisionOcrTests.cs b/tests/Filee.Engines.Tests/VisionOcrTests.cs index 424a957a..d31748a6 100644 --- a/tests/Filee.Engines.Tests/VisionOcrTests.cs +++ b/tests/Filee.Engines.Tests/VisionOcrTests.cs @@ -38,6 +38,15 @@ public void A_language_this_mac_has_no_model_for_is_left_to_tesseract() Assert.Equal(["fr-FR"], VisionLanguages.For("fra", ["fr-FR"])); } + [Fact] + public void Several_languages_are_asked_for_in_their_order_with_english_always_last() + { + Assert.Equal(["zh-Hans", "en-US"], VisionLanguages.For(["chi_sim", "eng"], Ventura)); + Assert.Equal(["zh-Hans", "en-US"], VisionLanguages.For(["eng", "chi_sim"], Ventura)); + Assert.Equal(["ko-KR", "ja-JP", "en-US"], VisionLanguages.For(["kor", "jpn"], Ventura)); + Assert.Null(VisionLanguages.For(["chi_sim", "hin"], Ventura)); // one recognizer reads the page: Tesseract, then + } + [Fact] public void Every_mapped_language_is_one_filee_offers() { @@ -337,6 +346,50 @@ public async Task A_language_vision_doesnt_read_goes_to_tesseract_or_asks_for_it Assert.Equal(OcrLanguages.Find("hin")!.NativeName, refused.Detail); } + [Fact] + public async Task Vision_reads_several_languages_at_once() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "mixed.png", 300, 200); + var recognizer = new VisionFakes.Recognizer(); + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(picture, "png", Path.Combine(folder, "mixed.pdf"), Ocr("chi_sim+eng")), null, TestContext.Current.CancellationToken); + + Assert.Equal(["zh-Hans", "en-US"], Assert.Single(recognizer.Calls).Languages); + } + + [Fact] + public async Task One_language_vision_doesnt_read_sends_them_all_to_tesseract_or_is_named_when_it_is_missing() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "mixed.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("chi_sim+hin")), null, TestContext.Current.CancellationToken); + + Assert.Equal(["tesseract"], written); + 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("chi_sim+hin+eng")), 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_a_combination_it_reads_with_one_language_fewer_each_time() + { + var recognizer = new VisionFakes.Recognizer { FailWithSeveralLanguages = true }; + + var lines = VisionConverter.Recognize(recognizer, "page.png", ["zh-Hans", "ko-KR", "en-US"], TestContext.Current.CancellationToken); + + Assert.Single(lines); + Assert.Equal([["zh-Hans", "ko-KR", "en-US"], ["zh-Hans", "ko-KR"], ["zh-Hans"]], recognizer.Calls.Select(c => c.Languages)); + } + [Fact] public void When_vision_refuses_two_languages_it_reads_the_first_alone() { From cf7bc5a6a74d88b873b878fbc13cc6c6225d43d9 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sat, 7 Nov 2026 03:15:19 +0900 Subject: [PATCH 2/3] fix(ocr): keep the pick, the main language and the sizes right From the review of #1229: - The "add a language" list is refreshed in place, so a language picked but not added stays picked when a row is removed or the main language changes; clearing the list made the combo box drop it. - An added language made the main one trades places with the old main language, which used to be lost. - Apple Vision keeps English first when it is the main language, so a refused combination falls back to it, not to the other language. - On a Mac, a language Vision can't read among the chosen ones sends them all to Tesseract, so the others show their download size too. For that the OCR lists hold OcrLanguageChoice, whose detail can change. - AppleVisionTests read Korean and English in both orders on real Vision. Co-Authored-By: Claude Opus 5.5 --- docs/ENGINES.md | 13 +- .../ViewModels/PresetEditorViewModel.cs | 84 +++++++++-- src/Filee.Engines/Ocr/VisionConverter.cs | 4 +- src/Filee.Engines/Ocr/VisionLanguages.cs | 8 +- tests/Filee.App.Tests/RenderTests.cs | 132 +++++++++++++++++- tests/Filee.Engines.Tests/VisionOcrTests.cs | 34 ++++- 6 files changed, 243 insertions(+), 32 deletions(-) diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 2821d62d..40ececf2 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -1914,12 +1914,13 @@ Tesseract and is registered before it, so the route planner takes it; on Windows ("Not available on this platform"). - The preset's languages (Tesseract codes) are mapped to Vision's identifiers (`VisionLanguages`) and asked for in their - order with English last, whether it was chosen or not; if Vision refuses the combination it reads with one language - fewer, down to the first 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, together with the preset's other languages, and is refused - otherwise with "Apple Vision can't read this language. Install Tesseract …" and its name. The preset editor shows no - download size for the languages Vision reads, and its hint names Apple Vision. + order. English is added when it wasn't chosen and goes last unless it is the main language; if Vision refuses the + combination it reads with one language fewer, down to the main 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, together with the preset's other + languages, and is refused otherwise with "Apple Vision can't read this language. Install Tesseract …" and its name. + The preset editor shows no download size for the languages Vision reads, unless such a language among the chosen ones + sends them all to Tesseract, 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. diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 4273d024..68ddf9ab 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -47,6 +47,27 @@ public sealed record Choice(T Value, string Label, string? Detail = null) : I public override string ToString() => Label; } +/// +/// A language in the preset editor's OCR lists. Whether it needs a download depends on the other languages chosen +/// (), so the lists keep one object per language and only its changes. +/// +/// The size of its download, or null when its data is there already. +public sealed partial class OcrLanguageChoice(string value, string label, bool visionReads, string? download) : ObservableObject, IChoice +{ + public string Value => value; + public string Label => label; + + /// Apple Vision reads it on this computer. + public bool VisionReads => visionReads; + + [ObservableProperty] private string? _detail; + + /// Tesseract reads the chosen languages, not Apple Vision: it needs the data of each. + public void ShowDownload(bool tesseractReads) => Detail = tesseractReads || !visionReads ? download : null; + + public override string ToString() => Label; +} + /// Selections bound to properties that always hold a value (a choice, the open page). public static class Choices { @@ -81,6 +102,12 @@ public sealed partial class PresetEditorViewModel : ObservableObject private readonly ILocalizer _loc; public PresetEditorViewModel(Preset preset, ILocalizer loc, IReadOnlySet? turnedOff = null) + : this(preset, loc, turnedOff, null) + { + } + + /// Which languages Apple Vision reads, for tests; by default what this computer's does. + internal PresetEditorViewModel(Preset preset, ILocalizer loc, IReadOnlySet? turnedOff, Func? visionReads) { _original = preset; _edit = preset.Clone(); @@ -106,14 +133,13 @@ .. 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. + // On a Mac, Apple Vision reads the common languages without a download (ShowOcrDownloads). 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) || vision.Reads(l.Code) ? null - : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; + .Select(l => new OcrLanguageChoice(l.Code, l.NativeName, (visionReads ?? vision.Reads)(l.Code), + OcrLanguages.IsInstalled(l.Code) ? null : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; // "Ask every time" (#521) is a bool of its own on the preset (OutputRule.AskEveryTime): its choice has no location. Locations = [.. Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])), new Choice(null, loc["presets.location.Ask"])]; @@ -323,13 +349,13 @@ .. WhisperLanguages.All public IReadOnlyList> PageSizes { get; } /// Languages Tesseract reads (PdfOptions.OcrLanguage), English first. - public IReadOnlyList> OcrLanguageChoices { get; } + public IReadOnlyList OcrLanguageChoices { get; } /// Languages read next to , for text that mixes them (#1123); PdfOptions.OcrLanguage holds them all. - public ObservableCollection> MoreOcrLanguages { get; } + public ObservableCollection MoreOcrLanguages { get; } /// The languages not chosen yet, for the "add a language" list. - public ObservableCollection> OcrLanguagesToAdd { get; } = []; + public ObservableCollection OcrLanguagesToAdd { get; } = []; /// The spoken languages Whisper recognizes (MediaOptions.SpokenLanguage), Auto first. public IReadOnlyList> SpokenLanguageChoices { get; } @@ -725,12 +751,12 @@ partial void OnPdfPasswordChanged(string value) /// file per page, compression (and with it removing metadata), page size and margin don't apply and are hidden. /// private bool Recognizes => ShowOcr && Ocr; - [ObservableProperty] private Choice _ocrLanguage; + [ObservableProperty] private OcrLanguageChoice _ocrLanguage; /// Picked in the "add a language" list, until adds it. [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(AddOcrLanguageCommand))] - private Choice? _ocrLanguageToAdd; + private OcrLanguageChoice? _ocrLanguageToAdd; public bool HasMoreOcrLanguages => MoreOcrLanguages.Count > 0; @@ -746,22 +772,50 @@ private void AddOcrLanguage() private bool CanAddOcrLanguage() => OcrLanguageToAdd is not null; [RelayCommand] - private void RemoveOcrLanguage(Choice language) => MoreOcrLanguages.Remove(language); + private void RemoveOcrLanguage(OcrLanguageChoice language) => MoreOcrLanguages.Remove(language); - partial void OnOcrLanguageChanged(Choice? oldValue, Choice newValue) + partial void OnOcrLanguageChanged(OcrLanguageChoice? oldValue, OcrLanguageChoice newValue) { if (Choices.Restore(oldValue, newValue, v => OcrLanguage = v)) return; - MoreOcrLanguages.Remove(newValue); // it is the main one now + // An added language made the main one trades places with it: the text still has the old main language. + if (oldValue is not null && MoreOcrLanguages.IndexOf(newValue) is >= 0 and var index) + MoreOcrLanguages[index] = oldValue; RefreshOcrLanguagesToAdd(); } + partial void OnOcrLanguageToAddChanged(OcrLanguageChoice? value) => ShowOcrDownloads(); + private void RefreshOcrLanguagesToAdd() { - OcrLanguagesToAdd.Clear(); - foreach (var language in OcrLanguageChoices.Where(l => l != OcrLanguage && !MoreOcrLanguages.Contains(l))) - OcrLanguagesToAdd.Add(language); + // In place, not cleared and filled again: a combo box drops its selection with its items, and a language picked + // but not added yet stays picked while it is still in the list. + var available = OcrLanguageChoices.Where(l => l != OcrLanguage && !MoreOcrLanguages.Contains(l)).ToList(); + for (var i = OcrLanguagesToAdd.Count - 1; i >= 0; i--) + { + if (!available.Contains(OcrLanguagesToAdd[i])) + OcrLanguagesToAdd.RemoveAt(i); + } + for (var i = 0; i < available.Count; i++) + { + if (i == OcrLanguagesToAdd.Count || OcrLanguagesToAdd[i] != available[i]) + OcrLanguagesToAdd.Insert(i, available[i]); + } + if (OcrLanguageToAdd is { } picked && !available.Contains(picked)) + OcrLanguageToAdd = null; OnPropertyChanged(nameof(HasMoreOcrLanguages)); + ShowOcrDownloads(); + } + + /// + /// One language Apple Vision doesn't read sends all the chosen ones to Tesseract, which needs the data of each + /// (VisionConverter): then the languages Vision reads show their download too. Off a Mac that is always so. + /// + private void ShowOcrDownloads() + { + var tesseractReads = !OcrLanguage.VisionReads || MoreOcrLanguages.Any(l => !l.VisionReads) || OcrLanguageToAdd is { VisionReads: false }; + foreach (var language in OcrLanguageChoices) + language.ShowDownload(tesseractReads); } /// PDF → image: one tall image of all pages. diff --git a/src/Filee.Engines/Ocr/VisionConverter.cs b/src/Filee.Engines/Ocr/VisionConverter.cs index 567645bf..240f062e 100644 --- a/src/Filee.Engines/Ocr/VisionConverter.cs +++ b/src/Filee.Engines/Ocr/VisionConverter.cs @@ -112,8 +112,8 @@ await Task.Run(() => } /// - /// Reads one picture. When Vision fails on a combination of languages it reads again without the last one (English, - /// which VisionLanguages adds, goes first), down to the first language alone. + /// Reads one picture. When Vision fails on a combination of languages it reads again without the last one (usually + /// the English that VisionLanguages adds), down to the first language, the main one, alone. /// internal static IReadOnlyList Recognize(ITextRecognizer recognizer, string picture, IReadOnlyList languages, CancellationToken cancellationToken) diff --git a/src/Filee.Engines/Ocr/VisionLanguages.cs b/src/Filee.Engines/Ocr/VisionLanguages.cs index 4aa68966..94216a29 100644 --- a/src/Filee.Engines/Ocr/VisionLanguages.cs +++ b/src/Filee.Engines/Ocr/VisionLanguages.cs @@ -44,9 +44,9 @@ internal static class VisionLanguages public static IReadOnlyList? For(string code, IReadOnlyCollection supported) => For([code], supported); /// - /// The languages to ask Vision for when the text is in : those languages in their order, then - /// English, always last (scans in other languages often have English words in them), or null when Vision doesn't - /// read one of them. + /// The languages to ask Vision for when the text is in : those languages in their order, the + /// main one first, and English last unless it is the main one (scans in other languages often have English words in + /// them, so it is there even when not chosen), or null when Vision doesn't read one of them. /// /// What this Mac's Vision reads (). public static IReadOnlyList? For(IReadOnlyList codes, IReadOnlyCollection supported) @@ -62,7 +62,7 @@ internal static class VisionLanguages return null; languages.Add(match); } - if (supported.FirstOrDefault(s => string.Equals(s, "en-US", StringComparison.OrdinalIgnoreCase)) is { } english) + if (supported.FirstOrDefault(s => string.Equals(s, "en-US", StringComparison.OrdinalIgnoreCase)) is { } english && languages.FirstOrDefault() != english) { languages.Remove(english); languages.Add(english); diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index 7a66e711..f502ee12 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -18,6 +18,7 @@ using Filee.Core.Localization; using Filee.Core.Profiles; using Filee.Core.Settings; +using Filee.Engines.Ocr; using Microsoft.Extensions.Logging.Abstractions; namespace Filee.App.Tests; @@ -1546,14 +1547,17 @@ public void Preset_editor_reads_and_saves_several_text_recognition_languages(str Assert.True(vm.Apply()); Assert.Equal("chi_sim+eng+deu+fra", preset.Pdf.OcrLanguage); - // Made the main language, an added one isn't listed twice, and the old main one can be added again. + // An added language made the main one trades places with the old main one, which is not lost. vm.OcrLanguage = vm.OcrLanguageChoices.Single(l => l.Value == "deu"); - Assert.Equal(["eng", "fra"], vm.MoreOcrLanguages.Select(l => l.Value)); - Assert.Contains(vm.OcrLanguagesToAdd, l => l.Value == "chi_sim"); + Assert.Equal(["eng", "chi_sim", "fra"], vm.MoreOcrLanguages.Select(l => l.Value)); + Assert.DoesNotContain(vm.OcrLanguagesToAdd, l => l.Value is "chi_sim" or "deu"); + Assert.True(vm.Apply()); + Assert.Equal("deu+eng+chi_sim+fra", preset.Pdf.OcrLanguage); // With the others removed, one code is left, as older settings have it. vm.RemoveOcrLanguageCommand.Execute(vm.MoreOcrLanguages[0]); vm.RemoveOcrLanguageCommand.Execute(vm.MoreOcrLanguages[0]); + vm.RemoveOcrLanguageCommand.Execute(vm.MoreOcrLanguages[0]); Assert.False(vm.HasMoreOcrLanguages); Assert.Contains(vm.OcrLanguagesToAdd, l => l.Value == "eng"); Assert.True(vm.Apply()); @@ -1568,6 +1572,128 @@ public void Preset_editor_reads_and_saves_several_text_recognition_languages(str Assert.Equal("eng", new PresetEditorViewModel(preset, loc).OcrLanguage.Value); } + [AvaloniaFact] + public void Preset_editor_shows_the_download_of_a_language_vision_reads_when_tesseract_would_read_them_all() + { + TestServices.EnsureInitialized("en"); + var loc = AppHost.Get(); + var preset = Filee.Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "searchable-pdf"); + static bool VisionReads(string code) => code != "hin"; // a Mac, whatever this computer is + string? Download(string code) => OcrLanguages.IsInstalled(code) ? null + : loc.Format("presets.ocr_language_size", $"{OcrLanguages.Find(code)!.Size / 1_000_000.0:0.#} MB"); + var afrikaans = Download("afr"); + Assert.SkipWhen(afrikaans is null, "Afrikaans is downloaded on this computer"); + + // Only languages Vision reads: nothing to download for them, and the one it doesn't read shows its own. + preset.Pdf.OcrLanguage = "afr+swa"; + var vm = new PresetEditorViewModel(preset, loc, null, VisionReads); + Assert.Null(vm.OcrLanguage.Detail); + Assert.Null(vm.MoreOcrLanguages.Single().Detail); + Assert.Equal(Download("hin"), vm.OcrLanguagesToAdd.Single(l => l.Value == "hin").Detail); + + var window = new PresetEditorWindow { DataContext = vm, Height = 1400 }; + window.Show(); + Pump(); + bool Shows(string? text) => window.GetVisualDescendants().OfType().Any(t => t.IsEffectivelyVisible && t.Text == text); + Assert.False(Shows(afrikaans)); + + // Picked (it counts on saving) or added, it sends them all to Tesseract: the rows say so, while the window is open. + vm.OcrLanguageToAdd = vm.OcrLanguagesToAdd.Single(l => l.Value == "hin"); + Pump(); + Assert.Equal(afrikaans, vm.OcrLanguage.Detail); + Assert.True(Shows(afrikaans)); + vm.AddOcrLanguageCommand.Execute(null); + Pump(); + Assert.Equal(afrikaans, vm.OcrLanguage.Detail); + Assert.Equal(Download("swa"), vm.MoreOcrLanguages.Single(l => l.Value == "swa").Detail); + + // Removed again, Vision reads them. + vm.RemoveOcrLanguageCommand.Execute(vm.MoreOcrLanguages.Single(l => l.Value == "hin")); + Pump(); + Assert.Null(vm.OcrLanguage.Detail); + Assert.False(Shows(afrikaans)); + window.Close(); + + // A preset saved with the mix shows them from the start, and off a Mac nothing changes. + preset.Pdf.OcrLanguage = "afr+hin"; + Assert.Equal(afrikaans, new PresetEditorViewModel(preset, loc, null, VisionReads).OcrLanguage.Detail); + preset.Pdf.OcrLanguage = "afr"; + Assert.Equal(afrikaans, new PresetEditorViewModel(preset, loc, null, _ => false).OcrLanguage.Detail); + } + + [AvaloniaFact] + public void A_language_picked_but_not_added_stays_picked_while_the_lists_change() + { + // In a shown window: the combo box is what drops its selection when its items go. + TestServices.EnsureInitialized("en"); + var loc = AppHost.Get(); + var preset = Filee.Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "searchable-pdf"); + + void Shown(Action act) + { + preset.Pdf.OcrLanguage = "chi_sim+eng"; + var vm = new PresetEditorViewModel(preset, loc); + var window = new PresetEditorWindow { DataContext = vm, Height = 1400 }; + window.Show(); + Pump(); + act(vm, window.GetVisualDescendants().OfType().Single(c => ReferenceEquals(c.ItemsSource, vm.OcrLanguagesToAdd))); + Pump(); + Assert.True(vm.Apply()); + window.Close(); + } + + // Removing a row, the pick is kept and the removed language can be added again. + Shown((vm, box) => + { + box.SelectedItem = vm.OcrLanguagesToAdd.Single(l => l.Value == "deu"); + Pump(); + Assert.Equal("deu", vm.OcrLanguageToAdd?.Value); + vm.RemoveOcrLanguageCommand.Execute(vm.MoreOcrLanguages.Single()); + Pump(); + Assert.Equal("deu", vm.OcrLanguageToAdd?.Value); + Assert.Same(vm.OcrLanguageToAdd, box.SelectedItem); + Assert.Contains(vm.OcrLanguagesToAdd, l => l.Value == "eng"); + }); + Assert.Equal("chi_sim+deu", preset.Pdf.OcrLanguage); + + // Another main language: the same. + Shown((vm, box) => + { + box.SelectedItem = vm.OcrLanguagesToAdd.Single(l => l.Value == "deu"); + Pump(); + vm.OcrLanguage = vm.OcrLanguageChoices.Single(l => l.Value == "fra"); + Pump(); + Assert.Same(vm.OcrLanguageToAdd, box.SelectedItem); + Assert.Equal("deu", vm.OcrLanguageToAdd?.Value); + }); + Assert.Equal("fra+eng+deu", preset.Pdf.OcrLanguage); + + // Adding what is picked and then picking on, each one counts once. + Shown((vm, box) => + { + box.SelectedItem = vm.OcrLanguagesToAdd.Single(l => l.Value == "deu"); + Pump(); + vm.AddOcrLanguageCommand.Execute(null); + Pump(); + Assert.Null(box.SelectedItem); + box.SelectedItem = vm.OcrLanguagesToAdd.Single(l => l.Value == "ita"); + Pump(); + }); + Assert.Equal("chi_sim+eng+deu+ita", preset.Pdf.OcrLanguage); + + // The pick made the main language: it left the list, so nothing is picked any more. + Shown((vm, box) => + { + box.SelectedItem = vm.OcrLanguagesToAdd.Single(l => l.Value == "deu"); + Pump(); + vm.OcrLanguage = vm.OcrLanguageChoices.Single(l => l.Value == "deu"); + Pump(); + Assert.Null(vm.OcrLanguageToAdd); + Assert.Null(box.SelectedItem); + }); + Assert.Equal("deu+eng", preset.Pdf.OcrLanguage); + } + [AvaloniaTheory] [InlineData("en")] [InlineData("ko")] diff --git a/tests/Filee.Engines.Tests/VisionOcrTests.cs b/tests/Filee.Engines.Tests/VisionOcrTests.cs index d31748a6..d47828c4 100644 --- a/tests/Filee.Engines.Tests/VisionOcrTests.cs +++ b/tests/Filee.Engines.Tests/VisionOcrTests.cs @@ -39,10 +39,11 @@ public void A_language_this_mac_has_no_model_for_is_left_to_tesseract() } [Fact] - public void Several_languages_are_asked_for_in_their_order_with_english_always_last() + public void Several_languages_are_asked_for_in_their_order_with_english_last_unless_it_is_the_main_one() { Assert.Equal(["zh-Hans", "en-US"], VisionLanguages.For(["chi_sim", "eng"], Ventura)); - Assert.Equal(["zh-Hans", "en-US"], VisionLanguages.For(["eng", "chi_sim"], Ventura)); + Assert.Equal(["en-US", "zh-Hans"], VisionLanguages.For(["eng", "chi_sim"], Ventura)); + Assert.Equal(["zh-Hans", "ko-KR", "en-US"], VisionLanguages.For(["chi_sim", "eng", "kor"], Ventura)); Assert.Equal(["ko-KR", "ja-JP", "en-US"], VisionLanguages.For(["kor", "jpn"], Ventura)); Assert.Null(VisionLanguages.For(["chi_sim", "hin"], Ventura)); // one recognizer reads the page: Tesseract, then } @@ -359,6 +360,19 @@ public async Task Vision_reads_several_languages_at_once() Assert.Equal(["zh-Hans", "en-US"], Assert.Single(recognizer.Calls).Languages); } + [Fact] + public async Task With_english_as_the_main_language_a_refused_combination_falls_back_to_english() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "mixed.png", 300, 200); + var recognizer = new VisionFakes.Recognizer { FailWithSeveralLanguages = true }; + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(picture, "png", Path.Combine(folder, "mixed.pdf"), Ocr("eng+chi_sim")), null, TestContext.Current.CancellationToken); + + Assert.Equal([["en-US", "zh-Hans"], ["en-US"]], recognizer.Calls.Select(c => c.Languages)); + } + [Fact] public async Task One_language_vision_doesnt_read_sends_them_all_to_tesseract_or_is_named_when_it_is_missing() { @@ -489,6 +503,22 @@ public async Task Korean_text_is_read_into_txt() Assert.Contains("보고서", text); } + [Theory] + [InlineData("kor+eng")] + [InlineData("eng+kor")] // English as the main language goes first: Vision has to read the Korean all the same + public async Task Text_in_two_languages_is_read_with_both(string languages) + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), Skip); + var scan = Scan(fx.NewFolder(), "scan.png", Gothic, "분기 보고서가 준비되었습니다", "The quarterly report is ready"); + + var job = await fx.ConvertAsync([scan], new Preset { TargetFormat = "txt", Pdf = { Ocr = true, OcrLanguage = languages } }); + + 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); + Assert.Contains("quarterly", text); + } + private static string Text(string pdf) { using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); From 2c868bd472949fa6bc71c6103d03acc24c25e786 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sat, 7 Nov 2026 05:10:33 +0900 Subject: [PATCH 3/3] fix(ocr): ask Apple Vision for Chinese, Japanese and Korean first With "eng+kor" Vision read the English line and dropped the Korean one on both Mac runners, while "kor+eng" read both. Those languages now go ahead of the others whatever order was picked; the rest keep theirs. Co-Authored-By: Claude Opus 5.5 --- src/Filee.Engines/Ocr/VisionLanguages.cs | 11 +++++++++-- tests/Filee.Engines.Tests/VisionOcrTests.cs | 13 +++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Filee.Engines/Ocr/VisionLanguages.cs b/src/Filee.Engines/Ocr/VisionLanguages.cs index 94216a29..ff35cebc 100644 --- a/src/Filee.Engines/Ocr/VisionLanguages.cs +++ b/src/Filee.Engines/Ocr/VisionLanguages.cs @@ -46,7 +46,9 @@ internal static class VisionLanguages /// /// The languages to ask Vision for when the text is in : those languages in their order, the /// main one first, and English last unless it is the main one (scans in other languages often have English words in - /// them, so it is there even when not chosen), or null when Vision doesn't read one of them. + /// them, so it is there even when not chosen), or null when Vision doesn't read one of them. Chinese, Japanese and + /// Korean go ahead of the others whatever the order: with English first, Vision drops a Korean line altogether, while + /// the other way around it reads both. /// /// What this Mac's Vision reads (). public static IReadOnlyList? For(IReadOnlyList codes, IReadOnlyCollection supported) @@ -67,6 +69,11 @@ internal static class VisionLanguages languages.Remove(english); languages.Add(english); } - return languages; + return [.. languages.OrderBy(l => IsCjk(l) ? 0 : 1)]; // a stable sort, so each group keeps its order } + + private static bool IsCjk(string language) => + language.StartsWith("zh-", StringComparison.OrdinalIgnoreCase) + || language.StartsWith("ja-", StringComparison.OrdinalIgnoreCase) + || language.StartsWith("ko-", StringComparison.OrdinalIgnoreCase); } diff --git a/tests/Filee.Engines.Tests/VisionOcrTests.cs b/tests/Filee.Engines.Tests/VisionOcrTests.cs index d47828c4..70c54a80 100644 --- a/tests/Filee.Engines.Tests/VisionOcrTests.cs +++ b/tests/Filee.Engines.Tests/VisionOcrTests.cs @@ -42,12 +42,21 @@ public void A_language_this_mac_has_no_model_for_is_left_to_tesseract() public void Several_languages_are_asked_for_in_their_order_with_english_last_unless_it_is_the_main_one() { Assert.Equal(["zh-Hans", "en-US"], VisionLanguages.For(["chi_sim", "eng"], Ventura)); - Assert.Equal(["en-US", "zh-Hans"], VisionLanguages.For(["eng", "chi_sim"], Ventura)); + Assert.Equal(["de-DE", "fr-FR", "en-US"], VisionLanguages.For(["deu", "fra"], Ventura)); + Assert.Equal(["en-US", "de-DE"], VisionLanguages.For(["eng", "deu"], Ventura)); Assert.Equal(["zh-Hans", "ko-KR", "en-US"], VisionLanguages.For(["chi_sim", "eng", "kor"], Ventura)); Assert.Equal(["ko-KR", "ja-JP", "en-US"], VisionLanguages.For(["kor", "jpn"], Ventura)); Assert.Null(VisionLanguages.For(["chi_sim", "hin"], Ventura)); // one recognizer reads the page: Tesseract, then } + [Fact] + public void Chinese_japanese_and_korean_are_asked_for_ahead_of_the_other_languages() + { + Assert.Equal(["ko-KR", "en-US"], VisionLanguages.For(["eng", "kor"], Ventura)); + Assert.Equal(["zh-Hans", "en-US"], VisionLanguages.For(["eng", "chi_sim"], Ventura)); + Assert.Equal(["ja-JP", "de-DE", "en-US"], VisionLanguages.For(["deu", "jpn"], Ventura)); + } + [Fact] public void Every_mapped_language_is_one_filee_offers() { @@ -505,7 +514,7 @@ public async Task Korean_text_is_read_into_txt() [Theory] [InlineData("kor+eng")] - [InlineData("eng+kor")] // English as the main language goes first: Vision has to read the Korean all the same + [InlineData("eng+kor")] // asked for in this order, Vision would drop the Korean line: VisionLanguages turns it around public async Task Text_in_two_languages_is_read_with_both(string languages) { Assert.SkipUnless(OperatingSystem.IsMacOS(), Skip);