diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 00f47735..40ececf2 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -1877,6 +1877,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. @@ -1905,12 +1913,14 @@ 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 +- The preset's languages (Tesseract codes) are mapped to Vision's identifiers (`VisionLanguages`) and asked for in their + 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, 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. + 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/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 8b51aa6a..b82ab8fd 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -905,6 +905,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 c14ff225..56bb161d 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -905,6 +905,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 833c9efb..d13aaa8c 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -920,6 +920,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 016df3d3..65f1a17e 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -905,6 +905,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 e6f341f8..06f0a732 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -920,6 +920,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 c48453d9..09374124 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -905,6 +905,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 2551da6e..fe2f86ec 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -905,6 +905,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 6e512889..f2b90040 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -904,6 +904,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 6aa8429a..b31c02bc 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -905,6 +905,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 bb5d2873..ffa78a5b 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -920,6 +920,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 dc49d246..fd296e80 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -905,6 +905,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 54ddb3c7..05c4ddf9 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -920,6 +920,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 87c5141e..736bfdd8 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -920,6 +920,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 deb42239..97855c39 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -920,6 +920,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 ba0a62a1..38d411aa 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -920,6 +920,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 69cd14a9..b69a54e4 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -905,6 +905,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 99a85861..c3050ba7 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -905,6 +905,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 a4b74617..d4c19500 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -920,6 +920,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 9b2002ca..81fec79d 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -920,6 +920,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 f456ed20..8aaf3d8f 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -920,6 +920,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..68ddf9ab 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; @@ -46,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 { @@ -80,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(); @@ -105,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"])]; @@ -182,7 +209,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); @@ -318,7 +349,14 @@ .. 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; } + + /// 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; } @@ -713,7 +751,72 @@ 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 OcrLanguageChoice? _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(OcrLanguageChoice language) => MoreOcrLanguages.Remove(language); + + partial void OnOcrLanguageChanged(OcrLanguageChoice? oldValue, OcrLanguageChoice newValue) + { + if (Choices.Restore(oldValue, newValue, v => OcrLanguage = v)) + return; + // 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() + { + // 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. [ObservableProperty] private bool _combinePages; @@ -1109,7 +1212,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 3a49a559..363d554d 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -1269,7 +1269,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..240f062e 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 (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) { - 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..ff35cebc 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,40 @@ 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, 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. 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(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.FirstOrDefault() != english) + { + languages.Remove(english); + languages.Add(english); + } + 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.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index 5cbc2643..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; @@ -1509,6 +1510,190 @@ 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); + + // 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", "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()); + 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); + } + + [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/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..70c54a80 100644 --- a/tests/Filee.Engines.Tests/VisionOcrTests.cs +++ b/tests/Filee.Engines.Tests/VisionOcrTests.cs @@ -38,6 +38,25 @@ 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_last_unless_it_is_the_main_one() + { + Assert.Equal(["zh-Hans", "en-US"], VisionLanguages.For(["chi_sim", "eng"], 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() { @@ -337,6 +356,63 @@ 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 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() + { + 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() { @@ -436,6 +512,22 @@ public async Task Korean_text_is_read_into_txt() Assert.Contains("보고서", text); } + [Theory] + [InlineData("kor+eng")] + [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); + 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);