diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 40cc80c9..493bc290 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -33,6 +33,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Pandoc | **optional download** (~42 MB, 240 MB on disk) | Markdown ↔ DOCX/ODT/RTF, DOCX/ODT/HTML/RTF → Markdown, HTML ↔ DOCX/ODT, markup languages (reStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter, Textile, man, OPML) ↔ Markdown/HTML/DOCX/ODT and each other (and → RTF, EPUB, TXT), Markdown and markup → PPTX slides | | Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it | | Tesseract | **optional download** (~50 MB, 160 MB on disk; each language 0.4–10 MB when first used) | **text recognition (OCR)**: scans and pictures → searchable PDF, and their text into DOCX, TXT, Markdown and HWPX ("Recognize text" in the preset, see below) | +| Apple Vision | part of macOS (Mac only) | **text recognition (OCR) on a Mac**, the same step as Tesseract without a download; Tesseract reads the languages Vision doesn't (see "Apple Vision on macOS") | | FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, Live / Motion Photos → their video, a clip of a video → an iPhone Live Photo or Android Motion Photo (experimental), images → HEIC, clips or audio files joined into one, pictures → a slideshow MP4, copies without metadata (see "Media tools") | | Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR; e-books → PDF (ranked above the HWPX writer, see below) | @@ -1557,3 +1558,32 @@ scans and pictures with Tesseract (`Ocr/TesseractConverter.cs`), in the language - The language data is copied into the job's folder and passed with `--tessdata-dir`, so a system Tesseract reads only Filee's pinned files; `-c tessedit_create_pdf=1` replaces the `pdf` config file that lives in Tesseract's own tessdata folder. + +### Apple Vision on macOS + +On a Mac the same step runs with Apple Vision (`Ocr/VisionConverter.cs`, `VNRecognizeTextRequest` at the accurate +level and the newest revision the Mac has), part of macOS, so nothing is downloaded. It has the same OCR edges as +Tesseract and is registered before it, so the route planner takes it; on Windows and Linux it is unavailable +("Not available on this platform"). + +- The preset's language (a Tesseract code) is mapped to Vision's identifiers (`VisionLanguages`) and asked for with + English next to it; if Vision refuses the pair it reads with the language alone. Which languages Vision reads comes + from the Mac itself (`supportedRecognitionLanguagesAndReturnError:`, more with newer macOS versions); a language it + doesn't read (Hindi, Nepali, Greek, …) goes to Tesseract when that is installed, and is refused otherwise with + "Apple Vision can't read this language. Install Tesseract …". The preset editor shows no download size for the + languages Vision reads, and its hint names Apple Vision. +- PDFs go page by page exactly like Tesseract's (`TesseractConverter.RecognizePdfAsync`, at most two pages at a time). + A JPEG picture goes into the PDF as it is, other pictures (and a JPEG that EXIF turns) as an upright PNG; every page + of a TIFF becomes a page. The page size comes from the picture's resolution, 300 dpi when it has none. +- Each page is the picture with the recognized words as invisible text (`OcrPdfWriter`, render mode 3): one font + size per line (its height), the baseline at the line's bottom, each word stretched (`Tz`) to where the next one + starts with its space, so search, selection and copying follow the picture. Vision gives a box per word + (`boundingBoxForRange:error:`); without one the words are spread over the line by their length. +- The text is set in a TrueType font with only empty glyphs, built in code (`GlyphlessFont`): every UTF-16 unit is + one character code (`Identity-H`), shown as glyph 1, and the `ToUnicode` map gives it back unchanged, so any + script works without a font in the file. `PdfDocumentReader` and `PagesWithText` read the layer like Tesseract's. +- Vision is called through the Objective-C runtime (`Ocr/AppleVision.cs`), without a bridging library. A `CGRect` + result needs `objc_msgSend_stret` on Intel Macs; objects made with `alloc`/`init` are released, the rest go with an + autorelease pool per page. A cancelled job cancels the running request. +- Tests: `VisionLanguageTests`, `OcrTextLayerTests` and `VisionConverterTests` (a stand-in recognizer) run everywhere; + `AppleVisionTests` read real pictures on macOS (CI's Apple silicon and Intel jobs). diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index d5f34485..299504c6 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -80,6 +80,7 @@ "error.cannot_turn": "التدوير والانعكاس متاحان للصور فقط", "error.no_transparency": "هذا التنسيق لا يدعم الشفافية، لذا لا يوجد ما يُقص", "error.ocr_language_download": "تعذّر تنزيل لغة التعرّف الضوئي OCR", + "error.ocr_needs_tesseract": "لا يستطيع Apple Vision قراءة هذه اللغة. ثبّت Tesseract من الإعدادات ← المحركات", "error.too_many_frames": "عدد الإطارات كبير جدًا لحفظها كصور. زِد قيمة «كل إطار رقم N»", "error.too_many_pictures": "عدد الصور كبير جدًا لفيديو واحد، قسّمها على عدة فيديوهات", "error.not_enough_space": "لا توجد مساحة كافية على القرص لحفظ الصور", @@ -147,6 +148,7 @@ "engine.pandoc.description": "التحويل بين Markdown وHTML وبين ODT وRTF، ومن DOCX إلى Markdown\nالتحويل بين reStructuredText وLaTeX وOrg وAsciiDoc وTypst وMediaWiki وDocBook وJupyter وغيرها وبين Markdown وHTML وDOCX وODT\nتحويل Markdown وOrg إلى شرائح PPTX", "engine.ghostscript.description": "تحويل EPS وPostScript وملفات Illustrator القديمة إلى PDF وصور؛ وتحويل PDF إلى EPS وPS.", "engine.tesseract.description": "يتعرّف على نص الصور والمستندات الممسوحة (OCR): PDF قابل للبحث وDOCX وTXT.", + "engine.vision.description": "يتعرّف على نص الصور والمستندات الممسوحة بأداة التعرّف على النص المدمجة في macOS (OCR): PDF قابل للبحث وDOCX وTXT. لا حاجة إلى أي تنزيل.", "engine.calibre.description": "صيغ الكتب الإلكترونية النادرة (LIT وLRF وCHM وPDB وRB…) وإخراج Kindle ‏(MOBI وAZW3).\nعند وضعه فوق محرك كتابة HWPX، يُنشئ أيضًا PDF من الكتب الإلكترونية (بسرعة أكبر بكثير للكتب الطويلة).", "engine.libreoffice.description": "قراءة الصيغ القديمة والأقل شيوعًا: DOC وPPT وPages وKeynote وNumbers وWordPerfect وClarisWorks وPublisher وVisio…\nوالحفظ بصيغ DOC وXLS وPPT وODT وODP وقوالب OpenDocument وEMF.", "profile.images": "الصور", @@ -537,6 +539,7 @@ "presets.pdf_ghostscript_hint": "غالبًا ما يُنتج حجمًا أصغر، لكنه يعيد كتابة الملف بالكامل.\nتُحفَظ النتيجة الأصغر.", "presets.ocr": "التعرّف على النص (OCR)", "presets.ocr_hint": "يصبح نص المستندات الممسوحة والصور قابلًا للبحث والتحرير. يتطلب Tesseract (الإعدادات ← المحركات).\nتبقى الصفحات التي تحتوي على نص كما هي.", + "presets.ocr_hint_macos": "يصبح نص المستندات الممسوحة والصور قابلًا للبحث والتحرير بأداة التعرّف على النص المدمجة في macOS (Apple Vision). اللغات التي لا تقرؤها تتطلب Tesseract (الإعدادات ← المحركات).\nتبقى الصفحات التي تحتوي على نص كما هي.", "presets.ocr_language": "لغة النص", "presets.ocr_language_size": "تنزيل {0}", "presets.pdf_keep_metadata": "الاحتفاظ بالبيانات الوصفية للصفحات والصور (XMP)", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 08c33f55..c6903615 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Rotate and flip work on images only", "error.no_transparency": "This format has no transparency, so there's nothing to trim", "error.ocr_language_download": "Couldn't download the OCR language", + "error.ocr_needs_tesseract": "Apple Vision can't read this language. Install Tesseract in Settings → Engines", "error.too_many_frames": "Too many frames to save as pictures, raise \"Every Nth frame\"", "error.too_many_pictures": "Too many pictures for one video, split them into several", "error.not_enough_space": "Not enough free disk space for the pictures", @@ -154,6 +155,7 @@ "engine.pandoc.description": "Markdown and HTML ↔ ODT and RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter and more ↔ Markdown, HTML, DOCX and ODT\nMarkdown and Org → PPTX slides", "engine.ghostscript.description": "EPS, PostScript and older Illustrator files to PDF and images; PDF to EPS and PS.", "engine.tesseract.description": "Recognizes the text of scans and pictures (OCR): searchable PDF, DOCX, TXT.", + "engine.vision.description": "Recognizes the text of scans and pictures with macOS's own text recognition (OCR): searchable PDF, DOCX, TXT. Nothing to download.", "engine.calibre.description": "Rare e-book formats (LIT, LRF, CHM, PDB, RB…) and Kindle output (MOBI, AZW3).\nAbove the HWPX writer, it also makes the PDFs of e-books (much faster for long books).", "engine.libreoffice.description": "Reads older and less common formats: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nAlso saves as DOC, XLS, PPT, ODT, ODP, OpenDocument templates and EMF.", @@ -552,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Often smaller, but it rewrites the whole file.\nThe smallest result is kept.", "presets.ocr": "Recognize text (OCR)", "presets.ocr_hint": "Scans and pictures get text you can search and edit. Needs Tesseract (Settings → Engines).\nPages that already have text stay as they are.", + "presets.ocr_hint_macos": "Scans and pictures get text you can search and edit, with macOS's own text recognition (Apple Vision). Languages it can't read need Tesseract (Settings → Engines).\nPages that already have text stay as they are.", "presets.ocr_language": "Language of the text", "presets.ocr_language_size": "{0} download", "presets.pdf_keep_metadata": "Keep page and picture metadata (XMP)", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index fbc629f4..616ba840 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -80,6 +80,7 @@ "error.cannot_turn": "Rotar y voltear solo funcionan con imágenes", "error.no_transparency": "Este formato no tiene transparencia, así que no hay nada que recortar", "error.ocr_language_download": "No se pudo descargar el idioma de OCR", + "error.ocr_needs_tesseract": "Apple Vision no puede leer este idioma. Instala Tesseract en Ajustes → Motores", "error.too_many_frames": "Demasiados fotogramas para guardar como imágenes; aumenta «Cada N fotogramas»", "error.too_many_pictures": "Demasiadas imágenes para un vídeo; divídelas en varios", "error.not_enough_space": "Espacio libre en disco insuficiente para las imágenes", @@ -147,6 +148,7 @@ "engine.pandoc.description": "Markdown y HTML ↔ ODT y RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter y más ↔ Markdown, HTML, DOCX y ODT\nMarkdown y Org → diapositivas PPTX", "engine.ghostscript.description": "EPS, PostScript y archivos antiguos de Illustrator a PDF e imágenes; PDF a EPS y PS.", "engine.tesseract.description": "Reconoce el texto de escaneos e imágenes (OCR): PDF con búsqueda, DOCX, TXT.", + "engine.vision.description": "Reconoce el texto de escaneos e imágenes con el reconocimiento de texto propio de macOS (OCR): PDF con búsqueda, DOCX, TXT. No hay nada que descargar.", "engine.calibre.description": "Formatos raros de libros electrónicos (LIT, LRF, CHM, PDB, RB…) y salida Kindle (MOBI, AZW3).\nPor encima del escritor HWPX, también crea los PDF de libros electrónicos (mucho más rápido para libros largos).", "engine.libreoffice.description": "Lee formatos antiguos y poco habituales: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nTambién guarda como DOC, XLS, PPT, ODT, ODP, plantillas OpenDocument y EMF.", "profile.images": "Imágenes", @@ -537,6 +539,7 @@ "presets.pdf_ghostscript_hint": "A menudo es más pequeño, pero reescribe todo el archivo.\nSe conserva el resultado más pequeño.", "presets.ocr": "Reconocer texto (OCR)", "presets.ocr_hint": "Los escaneos e imágenes tendrán texto que se puede buscar y editar. Requiere Tesseract (Ajustes → Motores).\nLas páginas que ya tienen texto no cambian.", + "presets.ocr_hint_macos": "Los escaneos e imágenes tendrán texto que se puede buscar y editar, con el reconocimiento de texto propio de macOS (Apple Vision). Los idiomas que no lee requieren Tesseract (Ajustes → Motores).\nLas páginas que ya tienen texto no cambian.", "presets.ocr_language": "Idioma del texto", "presets.ocr_language_size": "Descarga de {0}", "presets.pdf_keep_metadata": "Conservar metadatos de página e imagen (XMP)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index fbc0a30b..b9870909 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -86,6 +86,7 @@ "error.cannot_turn": "La rotation et le retournement ne s'appliquent qu'aux images", "error.no_transparency": "Ce format n'a pas de transparence : il n'y a rien à rogner", "error.ocr_language_download": "Impossible de télécharger la langue de l'OCR", + "error.ocr_needs_tesseract": "Apple Vision ne lit pas cette langue. Installez Tesseract dans Paramètres → Moteurs", "error.too_many_frames": "Trop d'images à enregistrer, augmentez « Une image sur N »", "error.too_many_pictures": "Trop d'images pour une seule vidéo, répartissez-les en plusieurs", "error.not_enough_space": "Pas assez d'espace disque libre pour les images", @@ -154,6 +155,7 @@ "engine.pandoc.description": "Markdown et HTML ↔ ODT et RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter et plus ↔ Markdown, HTML, DOCX et ODT\nMarkdown et Org → diapositives PPTX", "engine.ghostscript.description": "EPS, PostScript et anciens fichiers Illustrator en PDF et images ; PDF en EPS et PS.", "engine.tesseract.description": "Reconnaît le texte des numérisations et des images (OCR) : PDF consultable, DOCX, TXT.", + "engine.vision.description": "Reconnaît le texte des numérisations et des images avec la reconnaissance de texte intégrée à macOS (OCR) : PDF consultable, DOCX, TXT. Rien à télécharger.", "engine.calibre.description": "Formats de livres numériques rares (LIT, LRF, CHM, PDB, RB…) et sortie Kindle (MOBI, AZW3).\nPlacé au-dessus du rédacteur HWPX, il produit aussi les PDF des livres numériques (bien plus vite pour les longs livres).", "engine.libreoffice.description": "Lit les formats anciens et moins courants : DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nEnregistre aussi en DOC, XLS, PPT, ODT, ODP, modèles OpenDocument et EMF.", @@ -552,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Souvent plus léger, mais il réécrit tout le fichier.\nLe résultat le plus léger est conservé.", "presets.ocr": "Reconnaître le texte (OCR)", "presets.ocr_hint": "Le texte des numérisations et des images devient consultable et modifiable. Nécessite Tesseract (Paramètres → Moteurs).\nLes pages qui ont déjà du texte restent telles quelles.", + "presets.ocr_hint_macos": "Le texte des numérisations et des images devient consultable et modifiable grâce à la reconnaissance de texte intégrée à macOS (Apple Vision). Les langues qu'elle ne lit pas nécessitent Tesseract (Paramètres → Moteurs).\nLes pages qui ont déjà du texte restent telles quelles.", "presets.ocr_language": "Langue du texte", "presets.ocr_language_size": "Téléchargement de {0}", "presets.pdf_keep_metadata": "Conserver les métadonnées des pages et des images (XMP)", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index aa2b97dd..f97d9e4b 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -80,6 +80,7 @@ "error.cannot_turn": "घुमाना और पलटना केवल इमेज पर काम करता है", "error.no_transparency": "इस फ़ॉर्मैट में पारदर्शिता नहीं है, इसलिए काटने को कुछ नहीं है", "error.ocr_language_download": "OCR भाषा डाउनलोड नहीं हो सकी", + "error.ocr_needs_tesseract": "Apple Vision यह भाषा नहीं पढ़ सकता। सेटिंग्स → इंजन में Tesseract इंस्टॉल करें", "error.too_many_frames": "तस्वीरों के रूप में सहेजने के लिए बहुत ज़्यादा फ़्रेम हैं, \"हर Nवाँ फ़्रेम\" बढ़ाएँ", "error.too_many_pictures": "एक वीडियो के लिए तस्वीरें बहुत ज़्यादा हैं, इन्हें कई हिस्सों में बाँटें", "error.not_enough_space": "तस्वीरों के लिए डिस्क पर पर्याप्त खाली जगह नहीं है", @@ -147,6 +148,7 @@ "engine.pandoc.description": "Markdown और HTML ↔ ODT और RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter और अन्य ↔ Markdown, HTML, DOCX और ODT\nMarkdown और Org → PPTX स्लाइड", "engine.ghostscript.description": "EPS, PostScript और पुरानी Illustrator फ़ाइलें PDF और इमेज में; PDF से EPS और PS।", "engine.tesseract.description": "स्कैन और तस्वीरों का टेक्स्ट पहचानता है (OCR): खोजने योग्य PDF, DOCX, TXT।", + "engine.vision.description": "macOS की अपनी टेक्स्ट पहचान से स्कैन और तस्वीरों का टेक्स्ट पहचानता है (OCR): खोजने योग्य PDF, DOCX, TXT। कुछ डाउनलोड नहीं करना पड़ता।", "engine.calibre.description": "दुर्लभ ई-बुक फ़ॉर्मैट (LIT, LRF, CHM, PDB, RB…) और Kindle आउटपुट (MOBI, AZW3)।\nHWPX writer के ऊपर होने पर यह ई-बुक के PDF भी बनाता है (लंबी किताबों के लिए बहुत तेज़)।", "engine.libreoffice.description": "पुराने और कम प्रचलित फ़ॉर्मैट पढ़ता है: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nDOC, XLS, PPT, ODT, ODP, OpenDocument टेम्पलेट और EMF के रूप में सहेजता भी है।", "profile.images": "इमेज", @@ -537,6 +539,7 @@ "presets.pdf_ghostscript_hint": "अक्सर और छोटा होता है, लेकिन पूरी फ़ाइल को दोबारा लिखता है।\nसबसे छोटा नतीजा रखा जाता है।", "presets.ocr": "टेक्स्ट पहचानें (OCR)", "presets.ocr_hint": "स्कैन और तस्वीरों का टेक्स्ट खोजने और बदलने योग्य बनता है। Tesseract चाहिए (सेटिंग्स → इंजन)।\nजिन पेजों में पहले से टेक्स्ट है वे वैसे ही रहते हैं।", + "presets.ocr_hint_macos": "macOS की अपनी टेक्स्ट पहचान (Apple Vision) से स्कैन और तस्वीरों का टेक्स्ट खोजने और बदलने योग्य बनता है। जो भाषाएँ यह नहीं पढ़ सकता, उनके लिए Tesseract चाहिए (सेटिंग्स → इंजन)।\nजिन पेजों में पहले से टेक्स्ट है वे वैसे ही रहते हैं।", "presets.ocr_language": "टेक्स्ट की भाषा", "presets.ocr_language_size": "{0} डाउनलोड", "presets.pdf_keep_metadata": "पेज और तस्वीर का मेटाडेटा (XMP) रखें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index ede66293..026c77be 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -80,6 +80,7 @@ "error.cannot_turn": "回転と反転は画像でのみ使えます", "error.no_transparency": "この形式には透明部分がないため、切り取るものがありません", "error.ocr_language_download": "OCR の言語データをダウンロードできませんでした", + "error.ocr_needs_tesseract": "Apple Vision はこの言語を読めません。「設定」→「変換エンジン」で Tesseract をインストールしてください", "error.too_many_frames": "画像として保存するフレーム数が多すぎます。「Nフレームごと」の値を大きくしてください", "error.too_many_pictures": "1本の動画にするには画像が多すぎます。いくつかに分けてください", "error.not_enough_space": "画像を保存するための空き容量が不足しています", @@ -147,6 +148,7 @@ "engine.pandoc.description": "Markdown、HTMLとODT、RTFを相互変換し、DOCXをMarkdownに変換します。\nreStructuredText、LaTeX、Org、AsciiDoc、Typst、MediaWiki、DocBook、JupyterなどをMarkdown、HTML、DOCX、ODTと相互変換します。\nMarkdown、OrgからPPTXスライドを作成します。", "engine.ghostscript.description": "EPS、PostScript、古いIllustratorファイルをPDFや画像に変換し、PDFをEPSやPSに変換します。", "engine.tesseract.description": "スキャンや画像の文字を認識します(OCR):検索可能な PDF、DOCX、TXT。", + "engine.vision.description": "macOS 標準の文字認識で、スキャンや画像の文字を認識します(OCR):検索可能な PDF、DOCX、TXT。ダウンロードは不要です。", "engine.calibre.description": "一般的でない電子書籍形式(LIT、LRF、CHM、PDB、RBなど)を変換し、Kindle用のMOBI、AZW3形式で保存できます。\n「HWPX writer」より上に置くと、電子書籍のPDF作成もCalibreが担当します(長い本では大幅に速くなります)。", "engine.libreoffice.description": "古い形式や一般的でない形式(DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visioなど)を読み込みます。\nDOC、XLS、PPT、ODT、ODP、OpenDocumentテンプレート、EMF形式での保存にも対応します。", "profile.images": "画像", @@ -537,6 +539,7 @@ "presets.pdf_ghostscript_hint": "さらに小さくできることがありますが、ファイル全体を書き換えます。\n最も小さい結果を保持します。", "presets.ocr": "文字を認識する(OCR)", "presets.ocr_hint": "スキャンや画像の文字を検索・編集できるようにします。Tesseract が必要です(「設定」→「変換エンジン」)。\nすでにテキストがあるページはそのままです。", + "presets.ocr_hint_macos": "スキャンや画像の文字を、macOS 標準の文字認識(Apple Vision)で検索・編集できるようにします。読めない言語には Tesseract が必要です(「設定」→「変換エンジン」)。\nすでにテキストがあるページはそのままです。", "presets.ocr_language": "テキストの言語", "presets.ocr_language_size": "{0} をダウンロード", "presets.pdf_keep_metadata": "ページと画像のメタデータ(XMP)を保持", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 91d11c7e..1957ab72 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -86,6 +86,7 @@ "error.cannot_turn": "회전·반전은 이미지에만 할 수 있어요", "error.no_transparency": "이 형식에는 투명한 부분이 없어서 잘라낼 것이 없어요", "error.ocr_language_download": "OCR 언어 데이터를 내려받지 못했어요", + "error.ocr_needs_tesseract": "Apple Vision은 이 언어를 읽지 못해요. 설정 → 변환 엔진에서 Tesseract를 설치해 주세요", "error.too_many_frames": "프레임이 너무 많아요. 'N번째 프레임마다' 값을 늘려 주세요", "error.too_many_pictures": "한 동영상에 넣기엔 사진이 너무 많아요. 여러 번에 나눠 주세요", "error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요", @@ -154,6 +155,7 @@ "engine.pandoc.description": "마크다운·HTML ↔ ODT·RTF, DOCX → 마크다운\nreStructuredText·LaTeX·Org·AsciiDoc·Typst·MediaWiki·DocBook·Jupyter 등 ↔ 마크다운·HTML·DOCX·ODT\n마크다운·Org → PPTX 슬라이드", "engine.ghostscript.description": "EPS, 포스트스크립트, 예전 일러스트레이터 파일을 PDF와 이미지로, PDF를 EPS·PS로.", "engine.tesseract.description": "스캔본과 사진의 글자를 인식해요(OCR): 검색 가능한 PDF, DOCX, TXT.", + "engine.vision.description": "macOS에 들어 있는 문자 인식으로 스캔본과 사진의 글자를 인식해요(OCR): 검색 가능한 PDF, DOCX, TXT. 따로 내려받을 게 없어요.", "engine.calibre.description": "드문 전자책 형식(LIT, LRF, CHM, PDB, RB…)과 킨들용 출력(MOBI, AZW3).\nHWPX writer보다 위에 두면 전자책 PDF도 만들어요(긴 책은 훨씬 빨라요).", "engine.libreoffice.description": "DOC, PPT, Pages, Keynote, Numbers, 워드퍼펙트, ClarisWorks, Publisher, Visio 같은 오래되었거나 드문 형식을 읽어요.\nDOC·XLS·PPT·ODT·ODP, 오픈도큐먼트 서식 파일, EMF로 저장도 해요.", @@ -552,6 +554,7 @@ "presets.pdf_ghostscript_hint": "더 작아질 때가 많지만 파일 전체를 다시 써요.\n가장 작은 결과를 남겨요.", "presets.ocr": "텍스트 인식 (OCR)", "presets.ocr_hint": "스캔본과 사진의 글자를 검색하고 편집할 수 있게 돼요. Tesseract가 필요해요(설정 → 변환 엔진).\n이미 텍스트가 있는 페이지는 그대로 둬요.", + "presets.ocr_hint_macos": "스캔본과 사진의 글자를 macOS에 들어 있는 문자 인식(Apple Vision)으로 검색하고 편집할 수 있게 돼요. Apple Vision이 못 읽는 언어는 Tesseract가 필요해요(설정 → 변환 엔진).\n이미 텍스트가 있는 페이지는 그대로 둬요.", "presets.ocr_language": "텍스트 언어", "presets.ocr_language_size": "{0} 내려받기", "presets.pdf_keep_metadata": "페이지·이미지 메타데이터(XMP) 유지", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 17e5fd99..1f00a870 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -79,6 +79,7 @@ "error.no_route": "कुनै पनि इन्जिनले यो रूपान्तरण गर्न सक्दैन", "error.cannot_turn": "घुमाउने र उल्ट्याउने कार्य केवल छविहरूमा मात्र काम गर्छ", "error.ocr_language_download": "OCR भाषा डाउनलोड गर्न सकिएन", + "error.ocr_needs_tesseract": "Apple Vision ले यो भाषा पढ्न सक्दैन। सेटिङहरू → इन्जिनहरूमा Tesseract स्थापना गर्नुहोस्", "error.no_transparency": "यो ढाँचामा पारदर्शिता हुँदैन, त्यसैले काट्नुपर्ने केही छैन", "error.too_many_frames": "छविका रूपमा सुरक्षित गर्न धेरै फ्रेमहरू छन्, \"प्रत्येक N-औं फ्रेम\" बढाउनुहोस्", "error.too_many_pictures": "एउटा भिडियोका लागि धेरै तस्बिरहरू छन्, तिनलाई केही भागमा बाँड्नुहोस्", @@ -147,6 +148,7 @@ "engine.pandoc.description": "Markdown र HTML ↔ ODT र RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter र थप ↔ Markdown, HTML, DOCX र ODT\nMarkdown र Org → PPTX स्लाइडहरू", "engine.ghostscript.description": "EPS, PostScript र पुराना Illustrator फाइलहरूलाई PDF र छविहरूमा; PDF लाई EPS र PS मा।", "engine.tesseract.description": "स्क्यान र तस्बिरको पाठ पहिचान गर्छ (OCR): खोज्न मिल्ने PDF, DOCX, TXT।", + "engine.vision.description": "macOS को आफ्नै पाठ पहिचानले स्क्यान र तस्बिरको पाठ पहिचान गर्छ (OCR): खोज्न मिल्ने PDF, DOCX, TXT। केही डाउनलोड गर्नु पर्दैन।", "engine.calibre.description": "दुर्लभ इ-पुस्तक ढाँचाहरू (LIT, LRF, CHM, PDB, RB…) र Kindle आउटपुट (MOBI, AZW3)।\nHWPX writer भन्दा माथि राख्दा, यसले इ-पुस्तकका PDF पनि बनाउँछ (लामो पुस्तकका लागि धेरै छिटो)।", "engine.libreoffice.description": "पुराना र कम प्रचलित ढाँचाहरू पढ्छ: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nसाथै DOC, XLS, PPT, ODT, ODP, OpenDocument टेम्प्लेट र EMF को रूपमा सुरक्षित गर्दछ।", "profile.images": "छविहरू", @@ -537,6 +539,7 @@ "presets.pdf_ghostscript_hint": "प्रायः सानो हुन्छ, तर यसले सम्पूर्ण फाइल पुनः लेख्छ।\nसबैभन्दा सानो नतिजा राखिन्छ।", "presets.ocr": "पाठ पहिचान (OCR)", "presets.ocr_hint": "स्क्यान र तस्बिरको पाठ खोज्न र सम्पादन गर्न मिल्ने हुन्छ। Tesseract चाहिन्छ (सेटिङहरू → इन्जिनहरू)।\nपहिले नै पाठ भएका पृष्ठ जस्ताको तस्तै रहन्छन्।", + "presets.ocr_hint_macos": "macOS को आफ्नै पाठ पहिचान (Apple Vision) ले स्क्यान र तस्बिरको पाठ खोज्न र सम्पादन गर्न मिल्ने हुन्छ। यसले पढ्न नसक्ने भाषाका लागि Tesseract चाहिन्छ (सेटिङहरू → इन्जिनहरू)।\nपहिले नै पाठ भएका पृष्ठ जस्ताको तस्तै रहन्छन्।", "presets.ocr_language": "पाठको भाषा", "presets.ocr_language_size": "{0} डाउनलोड", "presets.pdf_keep_metadata": "पृष्ठ र तस्बिरको मेटाडाटा (XMP) राख्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 9f88452c..1d7ba4f4 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Obracanie i odbicie działają tylko na obrazach", "error.no_transparency": "Ten format nie ma przezroczystości, więc nie ma czego przycinać", "error.ocr_language_download": "Nie udało się pobrać języka OCR", + "error.ocr_needs_tesseract": "Apple Vision nie czyta tego języka. Zainstaluj Tesseract w Ustawienia → Silniki", "error.too_many_frames": "Za dużo klatek do zapisania jako obrazy, zwiększ „Co N-ta klatka”", "error.too_many_pictures": "Za dużo zdjęć na jedno wideo, podziel je na kilka", "error.not_enough_space": "Za mało wolnego miejsca na dysku na obrazy", @@ -154,6 +155,7 @@ "engine.pandoc.description": "Markdown i HTML ↔ ODT i RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter i inne ↔ Markdown, HTML, DOCX i ODT\nMarkdown i Org → slajdy PPTX", "engine.ghostscript.description": "EPS, PostScript i starsze pliki Illustratora do PDF i obrazów; PDF do EPS i PS.", "engine.tesseract.description": "Rozpoznaje tekst skanów i obrazów (OCR): przeszukiwalny PDF, DOCX, TXT.", + "engine.vision.description": "Rozpoznaje tekst skanów i obrazów wbudowanym rozpoznawaniem tekstu macOS (OCR): przeszukiwalny PDF, DOCX, TXT. Nic nie trzeba pobierać.", "engine.calibre.description": "Rzadkie formaty e-booków (LIT, LRF, CHM, PDB, RB…) i zapis dla Kindle (MOBI, AZW3).\nUmieszczony nad HWPX writer tworzy też PDF-y e-booków (znacznie szybciej przy długich książkach).", "engine.libreoffice.description": "Odczytuje starsze i rzadsze formaty: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nZapisuje też jako DOC, XLS, PPT, ODT, ODP, szablony OpenDocument i EMF.", @@ -552,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Często daje mniejszy plik, ale zapisuje cały plik od nowa.\nZostaje najmniejszy wynik.", "presets.ocr": "Rozpoznaj tekst (OCR)", "presets.ocr_hint": "Tekst skanów i obrazów da się przeszukiwać i edytować. Wymaga Tesseract (Ustawienia → Silniki).\nStrony, które mają już tekst, pozostają bez zmian.", + "presets.ocr_hint_macos": "Tekst skanów i obrazów da się przeszukiwać i edytować dzięki wbudowanemu rozpoznawaniu tekstu macOS (Apple Vision). Języki, których nie czyta, wymagają Tesseract (Ustawienia → Silniki).\nStrony, które mają już tekst, pozostają bez zmian.", "presets.ocr_language": "Język tekstu", "presets.ocr_language_size": "Pobieranie {0}", "presets.pdf_keep_metadata": "Zachowaj metadane stron i obrazów (XMP)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index ab9da6f1..b058e333 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Girar e espelhar só funcionam em imagens", "error.no_transparency": "Este formato não tem transparência, então não há nada para cortar", "error.ocr_language_download": "Não foi possível baixar o idioma do OCR", + "error.ocr_needs_tesseract": "O Apple Vision não lê este idioma. Instale o Tesseract em Configurações → Motores", "error.too_many_frames": "Quadros demais para salvar como imagens. Aumente “A cada N quadros”", "error.too_many_pictures": "Imagens demais para um vídeo; divida-as em vários", "error.not_enough_space": "Não há espaço livre suficiente em disco para as imagens", @@ -154,6 +155,7 @@ "engine.pandoc.description": "Markdown e HTML ↔ ODT e RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter e mais ↔ Markdown, HTML, DOCX e ODT\nMarkdown e Org → slides PPTX", "engine.ghostscript.description": "EPS, PostScript e arquivos antigos do Illustrator para PDF e imagens; PDF para EPS e PS.", "engine.tesseract.description": "Reconhece o texto de digitalizações e imagens (OCR): PDF pesquisável, DOCX, TXT.", + "engine.vision.description": "Reconhece o texto de digitalizações e imagens com o reconhecimento de texto do próprio macOS (OCR): PDF pesquisável, DOCX, TXT. Nada para baixar.", "engine.calibre.description": "Formatos raros de e-book (LIT, LRF, CHM, PDB, RB…) e saída Kindle (MOBI, AZW3).\nAcima do gravador de HWPX, também gera os PDFs de e-books (bem mais rápido em livros longos).", "engine.libreoffice.description": "Lê formatos antigos e menos comuns: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nTambém salva como DOC, XLS, PPT, ODT, ODP, modelos OpenDocument e EMF.", @@ -552,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Muitas vezes menor, mas reescreve o arquivo inteiro.\nO menor resultado é mantido.", "presets.ocr": "Reconhecer texto (OCR)", "presets.ocr_hint": "Digitalizações e imagens ganham texto que pode ser pesquisado e editado. Requer o Tesseract (Configurações → Motores).\nPáginas que já têm texto ficam como estão.", + "presets.ocr_hint_macos": "Digitalizações e imagens ganham texto que pode ser pesquisado e editado, com o reconhecimento de texto do próprio macOS (Apple Vision). Idiomas que ele não lê precisam do Tesseract (Configurações → Motores).\nPáginas que já têm texto ficam como estão.", "presets.ocr_language": "Idioma do texto", "presets.ocr_language_size": "Download de {0}", "presets.pdf_keep_metadata": "Manter metadados de página e imagem (XMP)", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 8aacb6aa..040bb4a4 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -86,6 +86,7 @@ "error.cannot_turn": "Поворот и отражение работают только с изображениями", "error.no_transparency": "В этом формате нет прозрачности, обрезать нечего", "error.ocr_language_download": "Не удалось скачать язык для OCR", + "error.ocr_needs_tesseract": "Apple Vision не читает этот язык. Установите Tesseract в разделе «Настройки → Движки»", "error.too_many_frames": "Слишком много кадров для сохранения картинками — увеличьте «Каждый N-й кадр»", "error.too_many_pictures": "Слишком много картинок для одного видео — разделите их на несколько", "error.not_enough_space": "Не хватает места на диске для картинок", @@ -154,6 +155,7 @@ "engine.pandoc.description": "Markdown и HTML ↔ ODT и RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter и другие ↔ Markdown, HTML, DOCX и ODT\nMarkdown и Org → слайды PPTX", "engine.ghostscript.description": "EPS, PostScript и старые файлы Illustrator — в PDF и картинки; PDF — в EPS и PS.", "engine.tesseract.description": "Распознаёт текст сканов и изображений (OCR): PDF с поиском, DOCX, TXT.", + "engine.vision.description": "Распознаёт текст сканов и изображений встроенным в macOS распознаванием текста (OCR): PDF с поиском, DOCX, TXT. Ничего скачивать не нужно.", "engine.calibre.description": "Редкие форматы электронных книг (LIT, LRF, CHM, PDB, RB…) и вывод для Kindle (MOBI, AZW3).\nЕсли стоит выше HWPX writer, он же делает PDF из электронных книг (для длинных книг намного быстрее).", "engine.libreoffice.description": "Читает старые и редкие форматы: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nА ещё сохраняет в DOC, XLS, PPT, ODT, ODP, шаблоны OpenDocument и EMF.", @@ -552,6 +554,7 @@ "presets.pdf_ghostscript_hint": "Часто получается меньше, но он перезаписывает весь файл.\nОстаётся самый маленький результат.", "presets.ocr": "Распознать текст (OCR)", "presets.ocr_hint": "Текст сканов и изображений можно будет искать и редактировать. Нужен Tesseract (Настройки → Движки).\nСтраницы, где уже есть текст, не меняются.", + "presets.ocr_hint_macos": "Текст сканов и изображений можно будет искать и редактировать с помощью встроенного в macOS распознавания текста (Apple Vision). Для языков, которые оно не читает, нужен Tesseract (Настройки → Движки).\nСтраницы, где уже есть текст, не меняются.", "presets.ocr_language": "Язык текста", "presets.ocr_language_size": "Загрузка {0}", "presets.pdf_keep_metadata": "Сохранять метаданные страниц и картинок (XMP)", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 013e31ff..5b051af4 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -79,6 +79,7 @@ "error.no_route": "ไม่มีเครื่องมือที่แปลงฟอร์แมตนี้ได้", "error.cannot_turn": "การหมุนและพลิกภาพใช้ได้กับไฟล์รูปภาพเท่านั้น", "error.ocr_language_download": "ดาวน์โหลดภาษาสำหรับ OCR ไม่ได้", + "error.ocr_needs_tesseract": "Apple Vision อ่านภาษานี้ไม่ได้ ติดตั้ง Tesseract ได้ในการตั้งค่า → เครื่องมือแปลงไฟล์", "error.no_transparency": "ฟอร์แมตนี้ไม่มีความโปร่งใส จึงไม่มีขอบให้ตัดออก", "error.too_many_frames": "มีจำนวนเฟรมมากเกินกว่าจะบันทึกเป็นรูปภาพได้ ลองเพิ่มค่า \"บันทึกทุกๆ N เฟรม\"", "error.too_many_pictures": "รูปภาพมากเกินไปสำหรับวิดีโอเดียว ลองแบ่งเป็นหลายวิดีโอ", @@ -147,6 +148,7 @@ "engine.pandoc.description": "Markdown และ HTML ↔ ODT และ RTF, DOCX → Markdown\nreStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter และอื่นๆ ↔ Markdown, HTML, DOCX และ ODT\nMarkdown และ Org → สไลด์ PPTX", "engine.ghostscript.description": "EPS, PostScript และไฟล์ Illustrator รุ่นเก่าเป็น PDF และรูปภาพ แปลง PDF เป็น EPS และ PS", "engine.tesseract.description": "อ่านข้อความจากภาพสแกนและรูปภาพ (OCR): PDF ที่ค้นหาได้, DOCX, TXT", + "engine.vision.description": "อ่านข้อความจากภาพสแกนและรูปภาพด้วยระบบรู้จำข้อความในตัวของ macOS (OCR): PDF ที่ค้นหาได้, DOCX, TXT ไม่ต้องดาวน์โหลดอะไรเพิ่ม", "engine.calibre.description": "ฟอร์แมตอีบุ๊กที่หาได้ยาก (LIT, LRF, CHM, PDB, RB…) และไฟล์สำหรับ Kindle (MOBI, AZW3)\nเมื่อวางไว้เหนือตัวเขียน HWPX จะทำหน้าที่สร้าง PDF ของอีบุ๊กด้วย (เร็วกว่ามากสำหรับหนังสือเล่มหนา)", "engine.libreoffice.description": "อ่านฟอร์แมตรุ่นเก่าและฟอร์แมตที่พบน้อย: DOC, PPT, Pages, Keynote, Numbers, WordPerfect, ClarisWorks, Publisher, Visio…\nและบันทึกเป็น DOC, XLS, PPT, ODT, ODP, แม่แบบ OpenDocument และ EMF", "profile.images": "รูปภาพ", @@ -537,6 +539,7 @@ "presets.pdf_ghostscript_hint": "มักจะได้ไฟล์ที่เล็กลง แต่อาจเขียนโครงสร้างไฟล์ใหม่ทั้งหมด\nระบบจะเลือกเก็บไฟล์ผลลัพธ์ที่ขนาดเล็กที่สุดไว้", "presets.ocr": "รู้จำข้อความ (OCR)", "presets.ocr_hint": "ข้อความในภาพสแกนและรูปภาพจะค้นหาและแก้ไขได้ ต้องใช้ Tesseract (การตั้งค่า → เครื่องมือแปลงไฟล์)\nหน้าที่มีข้อความอยู่แล้วจะคงไว้ตามเดิม", + "presets.ocr_hint_macos": "ข้อความในภาพสแกนและรูปภาพจะค้นหาและแก้ไขได้ด้วยระบบรู้จำข้อความในตัวของ macOS (Apple Vision) ภาษาที่อ่านไม่ได้ต้องใช้ Tesseract (การตั้งค่า → เครื่องมือแปลงไฟล์)\nหน้าที่มีข้อความอยู่แล้วจะคงไว้ตามเดิม", "presets.ocr_language": "ภาษาของข้อความ", "presets.ocr_language_size": "ดาวน์โหลด {0}", "presets.pdf_keep_metadata": "คงเมทาดาตาของหน้าและรูปภาพ (XMP)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index a880dc4c..03c641f5 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -86,6 +86,7 @@ "error.cannot_turn": "只能旋转和翻转图片", "error.no_transparency": "此格式没有透明部分,无需裁剪", "error.ocr_language_download": "无法下载 OCR 语言数据", + "error.ocr_needs_tesseract": "Apple Vision 无法识别此语言。请在“设置 → 转换引擎”中安装 Tesseract", "error.too_many_frames": "帧数太多,请调大“每 N 帧”", "error.too_many_pictures": "图片太多,无法放进一个视频,请分成几次", "error.not_enough_space": "磁盘空间不足,无法保存这些图片", @@ -154,6 +155,7 @@ "engine.pandoc.description": "Markdown 和 HTML ↔ ODT、RTF,DOCX → Markdown\nreStructuredText、LaTeX、Org、AsciiDoc、Typst、MediaWiki、DocBook、Jupyter 等 ↔ Markdown、HTML、DOCX、ODT\nMarkdown 和 Org → PPTX 幻灯片", "engine.ghostscript.description": "EPS、PostScript 和旧版 Illustrator 文件转为 PDF 和图片;PDF 转为 EPS 和 PS。", "engine.tesseract.description": "识别扫描件和图片中的文字(OCR):可搜索的 PDF、DOCX、TXT。", + "engine.vision.description": "使用 macOS 自带的文字识别来识别扫描件和图片中的文字(OCR):可搜索的 PDF、DOCX、TXT。无需下载。", "engine.calibre.description": "少见的电子书格式(LIT、LRF、CHM、PDB、RB…)以及 Kindle 输出(MOBI、AZW3)。\n排在 HWPX writer 之上时,也由它把电子书转为 PDF(长书快得多)。", "engine.libreoffice.description": "读取较旧或少见的格式,如 DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visio。\n也能保存为 DOC、XLS、PPT、ODT、ODP、OpenDocument 模板或 EMF。", @@ -552,6 +554,7 @@ "presets.pdf_ghostscript_hint": "通常更小,但会重写整个文件。\n保留最小的结果。", "presets.ocr": "识别文字(OCR)", "presets.ocr_hint": "让扫描件和图片中的文字可以搜索和编辑。需要 Tesseract(设置 → 转换引擎)。\n已有文字的页面保持不变。", + "presets.ocr_hint_macos": "使用 macOS 自带的文字识别(Apple Vision),让扫描件和图片中的文字可以搜索和编辑。它无法识别的语言需要 Tesseract(设置 → 转换引擎)。\n已有文字的页面保持不变。", "presets.ocr_language": "文字的语言", "presets.ocr_language_size": "下载 {0}", "presets.pdf_keep_metadata": "保留页面和图片元数据 (XMP)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 2e36d0d1..37720329 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -86,6 +86,7 @@ "error.cannot_turn": "只能旋轉和翻轉圖片", "error.no_transparency": "此格式沒有透明部分,無需裁切", "error.ocr_language_download": "無法下載 OCR 語言資料", + "error.ocr_needs_tesseract": "Apple Vision 無法辨識此語言。請在「設定 → 轉換引擎」中安裝 Tesseract", "error.too_many_frames": "影格數太多,請調大「每 N 個影格」", "error.too_many_pictures": "圖片太多,無法放進同一部影片,請分成幾次", "error.not_enough_space": "磁碟空間不足,無法儲存這些圖片", @@ -154,6 +155,7 @@ "engine.pandoc.description": "Markdown 和 HTML ↔ ODT、RTF,DOCX → Markdown\nreStructuredText、LaTeX、Org、AsciiDoc、Typst、MediaWiki、DocBook、Jupyter 等 ↔ Markdown、HTML、DOCX、ODT\nMarkdown 和 Org → PPTX 投影片", "engine.ghostscript.description": "EPS、PostScript 和舊版 Illustrator 檔案轉為 PDF 和圖片;PDF 轉為 EPS 和 PS。", "engine.tesseract.description": "辨識掃描檔和圖片中的文字(OCR):可搜尋的 PDF、DOCX、TXT。", + "engine.vision.description": "使用 macOS 內建的文字辨識來辨識掃描檔和圖片中的文字(OCR):可搜尋的 PDF、DOCX、TXT。不需要下載。", "engine.calibre.description": "少見的電子書格式(LIT、LRF、CHM、PDB、RB…)以及 Kindle 輸出(MOBI、AZW3)。\n排在 HWPX writer 之上時,也由它把電子書轉為 PDF(長書快得多)。", "engine.libreoffice.description": "讀取較舊或少見的格式,如 DOC、PPT、Pages、Keynote、Numbers、WordPerfect、ClarisWorks、Publisher、Visio。\n也能儲存為 DOC、XLS、PPT、ODT、ODP、OpenDocument 範本或 EMF。", @@ -552,6 +554,7 @@ "presets.pdf_ghostscript_hint": "通常更小,但會重寫整個檔案。\n保留最小的結果。", "presets.ocr": "辨識文字(OCR)", "presets.ocr_hint": "讓掃描檔和圖片中的文字可以搜尋和編輯。需要 Tesseract(設定 → 轉換引擎)。\n已有文字的頁面保持不變。", + "presets.ocr_hint_macos": "使用 macOS 內建的文字辨識(Apple Vision),讓掃描檔和圖片中的文字可以搜尋和編輯。它無法辨識的語言需要 Tesseract(設定 → 轉換引擎)。\n已有文字的頁面保持不變。", "presets.ocr_language": "文字的語言", "presets.ocr_language_size": "下載 {0}", "presets.pdf_keep_metadata": "保留頁面和圖片中繼資料 (XMP)", diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 1fee4e9b..8280cd90 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -83,10 +83,14 @@ .. FormatRegistry.Known PageSizes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.page_size.{v}"])).ToList(); CompressionLevels = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.pdf_compression.{v}"])).ToList(); // Each language under its own name; one not downloaded yet shows its size (it downloads with the first file). + // On a Mac, Apple Vision reads the common languages without a download. + var vision = new VisionConverter(); + OcrHint = loc[vision.GetStatus().IsAvailable ? "presets.ocr_hint_macos" : "presets.ocr_hint"]; OcrLanguageChoices = [.. OcrLanguages.All .OrderBy(l => l.Code != OcrLanguages.Default).ThenBy(l => l.NativeName, StringComparer.CurrentCulture) .Select(l => new Choice(l.Code, l.NativeName, - OcrLanguages.IsInstalled(l.Code) ? null : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; + OcrLanguages.IsInstalled(l.Code) || vision.Reads(l.Code) ? null + : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; Locations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])).ToList(); Conflicts = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.conflict.{v}"])).ToList(); TiffCompressions = Enum.GetValues().Select(v => new Choice(v, v.ToString().ToUpperInvariant())).ToList(); @@ -191,6 +195,9 @@ .. FormatRegistry.Known /// Languages Tesseract reads (PdfOptions.OcrLanguage), English first. public IReadOnlyList> OcrLanguageChoices { get; } + /// What "Recognize text" needs: Tesseract, or on a Mac Apple Vision (Tesseract only for other languages). + public string OcrHint { get; } + /// PDF → PDF: don't compress, or the level (pictures at 72, 150 or 300 dpi). public IReadOnlyList> CompressionLevels { get; } public IReadOnlyList> Locations { get; } diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 81a1db4d..d61cef22 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -325,10 +325,10 @@ - + - + diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs index de4200a0..96fafe61 100644 --- a/src/Filee.Engines/EngineRegistry.cs +++ b/src/Filee.Engines/EngineRegistry.cs @@ -67,6 +67,7 @@ public static IReadOnlyList CreateAll(EngineEnvironment env, ILogger new EbookConverter(), new PandocConverter(), new GhostscriptConverter(), + new VisionConverter(), // before Tesseract: a Mac's OCR step (Tesseract reads the languages Vision doesn't) new TesseractConverter(), new CalibreConverter(), new LibreOfficeConverter(env, loggers?.CreateLogger()), diff --git a/src/Filee.Engines/Ocr/AppleVision.cs b/src/Filee.Engines/Ocr/AppleVision.cs new file mode 100644 index 00000000..0a69ae6f --- /dev/null +++ b/src/Filee.Engines/Ocr/AppleVision.cs @@ -0,0 +1,247 @@ +// Text recognition with Apple's Vision framework (VNRecognizeTextRequest), part of macOS: nothing to download, and +// it runs on the Neural Engine or GPU. Called through the Objective-C runtime (objc_msgSend) so Filee needs no +// bridging library; the framework is loaded only on macOS, the first time it is asked for. +// +// A CGRect (four doubles, 32 bytes) comes back in registers on Apple silicon but through memory on Intel Macs, where +// it needs objc_msgSend_stret. Objects Filee creates with alloc/init are released, the ones Vision hands back are +// autoreleased and go with the autorelease pool of each call. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Filee.Engines.Ocr; + +/// Reads the text of a picture: what needs from Apple Vision. +internal interface ITextRecognizer +{ + /// True when the recognizer can run on this computer. + bool IsAvailable { get; } + + /// The languages it reads, as Vision identifiers (en-US, zh-Hans). + IReadOnlyCollection Languages { get; } + + /// The lines of text in , in (most likely first). + IReadOnlyList Recognize(string picture, IReadOnlyList languages, CancellationToken cancellationToken); +} + +/// Apple Vision's text recognition, accurate level, newest revision of this macOS. +[SupportedOSPlatform("macos")] +internal sealed class AppleVision : ITextRecognizer +{ + private const string Vision = "/System/Library/Frameworks/Vision.framework/Vision"; + private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + private const string ObjC = "/usr/lib/libobjc.A.dylib"; + private const uint Utf8 = 0x08000100; // kCFStringEncodingUTF8 + private const nint Accurate = 0; // VNRequestTextRecognitionLevelAccurate + + private static readonly Lazy Loaded = new(() => + NativeLibrary.TryLoad(Vision, out _) && objc_getClass("VNRecognizeTextRequest") != 0); + + private static readonly Lazy> Supported = new(ReadLanguages); + + public bool IsAvailable => Loaded.Value; + + public IReadOnlyCollection Languages => IsAvailable ? Supported.Value : []; + + public IReadOnlyList Recognize(string picture, IReadOnlyList languages, CancellationToken cancellationToken) + { + if (!IsAvailable) + throw new PlatformNotSupportedException("Apple Vision text recognition is not available."); + var pool = New("NSAutoreleasePool"); + nint request = 0; + nint handler = 0; + var path = CFStringCreateWithCString(0, picture, Utf8); + try + { + request = NewRequest(); + SendVoid(request, Sel("setUsesLanguageCorrection:"), 1); + var wanted = Send(objc_getClass("NSMutableArray"), Sel("array")); + foreach (var language in languages) + { + var name = CFStringCreateWithCString(0, language, Utf8); + Send(wanted, Sel("addObject:"), name); + CFRelease(name); + } + Send(request, Sel("setRecognitionLanguages:"), wanted); + + var url = Send(objc_getClass("NSURL"), Sel("fileURLWithPath:"), path); + var options = Send(objc_getClass("NSDictionary"), Sel("dictionary")); + handler = Send(Send(objc_getClass("VNImageRequestHandler"), Sel("alloc")), Sel("initWithURL:options:"), url, options); + var requests = Send(objc_getClass("NSArray"), Sel("arrayWithObject:"), request); + bool done; + nint error; + using (cancellationToken.Register(() => Send(request, Sel("cancel")))) + done = Perform(handler, Sel("performRequests:error:"), requests, out error); + cancellationToken.ThrowIfCancellationRequested(); + if (!done) + throw new InvalidOperationException($"Apple Vision couldn't read the picture. {Describe(error)}".Trim()); + return Lines(Send(request, Sel("results"))); + } + finally + { + if (handler != 0) + Send(handler, Sel("release")); + if (request != 0) + Send(request, Sel("release")); + if (path != 0) + CFRelease(path); + Send(pool, Sel("drain")); + } + } + + /// The recognized lines, each with a box per word (estimated from the line when Vision gives none). + private static List Lines(nint observations) + { + var lines = new List(); + var count = observations == 0 ? 0 : (long)Send(observations, Sel("count")); + for (long i = 0; i < count; i++) + { + var observation = Send(observations, Sel("objectAtIndex:"), (nint)i); + var candidates = Send(observation, Sel("topCandidates:"), 1); + if (candidates == 0 || Send(candidates, Sel("count")) == 0) + continue; + var candidate = Send(candidates, Sel("objectAtIndex:"), 0); + var text = ToText(Send(candidate, Sel("string"))); + if (string.IsNullOrWhiteSpace(text)) + continue; + var box = Rect(observation, Sel("boundingBox")); + var estimate = OcrLine.Estimate(text, box.X, box.Y, box.Width, box.Height); + var words = new List(); + var ranges = OcrLine.WordRanges(text); + for (var w = 0; w < ranges.Count; w++) + { + var (start, length) = ranges[w]; + // NSString ranges count UTF-16 units, as .NET strings do. + var wordBox = SendRange(candidate, Sel("boundingBoxForRange:error:"), new NSRange((nuint)start, (nuint)length), out _); + if (wordBox == 0) + { + words.Add(estimate.Words[w]); + continue; + } + var rect = Rect(wordBox, Sel("boundingBox")); + words.Add(new OcrWord(text.Substring(start, length), rect.X, rect.Width)); + } + lines.Add(new OcrLine(box.X, box.Y, box.Width, box.Height, words)); + } + return lines; + } + + /// A request at the accurate level and the newest revision this macOS has (owned: release it). + private static nint NewRequest() + { + var type = objc_getClass("VNRecognizeTextRequest"); + var request = Send(Send(type, Sel("alloc")), Sel("init")); + Send(request, Sel("setRecognitionLevel:"), Accurate); + // Without it the revision depends on the SDK the program was built with, which for .NET's host may be old. + var revisions = Send(type, Sel("supportedRevisions")); + if (revisions != 0) + { + var newest = Send(revisions, Sel("lastIndex")); + if (newest != nint.MaxValue) // NSNotFound + Send(request, Sel("setRevision:"), newest); + } + return request; + } + + private static IReadOnlyCollection ReadLanguages() + { + var pool = New("NSAutoreleasePool"); + var request = NewRequest(); + try + { + var list = SendWithError(request, Sel("supportedRecognitionLanguagesAndReturnError:"), out _); + if (list == 0) + return VisionLanguages.Fallback; + var languages = new List(); + var count = (long)Send(list, Sel("count")); + for (long i = 0; i < count; i++) + { + if (ToText(Send(list, Sel("objectAtIndex:"), (nint)i)) is { Length: > 0 } language) + languages.Add(language); + } + return languages.Count > 0 ? languages : VisionLanguages.Fallback; + } + finally + { + Send(request, Sel("release")); + Send(pool, Sel("drain")); + } + } + + private static string? Describe(nint error) => + error == 0 ? null : ToText(Send(error, Sel("localizedDescription"))); + + private static string? ToText(nint nsString) => + nsString == 0 ? null : Marshal.PtrToStringUTF8(Send(nsString, Sel("UTF8String"))); + + private static nint New(string type) => Send(Send(objc_getClass(type), Sel("alloc")), Sel("init")); + + private static nint Sel(string name) => sel_registerName(name); + + private static CGRect Rect(nint receiver, nint selector) + { + if (RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + SendRectStret(out var rect, receiver, selector); + return rect; + } + return SendRect(receiver, selector); + } + + [StructLayout(LayoutKind.Sequential)] + private struct CGRect + { + public double X; + public double Y; + public double Width; + public double Height; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct NSRange(nuint location, nuint length) + { + public readonly nuint Location = location; + public readonly nuint Length = length; + } + + [DllImport(CoreFoundation)] + private static extern nint CFStringCreateWithCString(nint allocator, [MarshalAs(UnmanagedType.LPUTF8Str)] string value, uint encoding); + + [DllImport(CoreFoundation)] + private static extern void CFRelease(nint value); + + [DllImport(ObjC)] + private static extern nint objc_getClass([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC)] + private static extern nint sel_registerName([MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector, nint argument); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint Send(nint receiver, nint selector, nint first, nint second); + + /// A method with one BOOL argument and no result. + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern void SendVoid(nint receiver, nint selector, byte value); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint SendWithError(nint receiver, nint selector, out nint error); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern nint SendRange(nint receiver, nint selector, NSRange range, out nint error); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool Perform(nint receiver, nint selector, nint requests, out nint error); + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + private static extern CGRect SendRect(nint receiver, nint selector); + + [DllImport(ObjC, EntryPoint = "objc_msgSend_stret")] + private static extern void SendRectStret(out CGRect result, nint receiver, nint selector); +} diff --git a/src/Filee.Engines/Ocr/OcrTextLayer.cs b/src/Filee.Engines/Ocr/OcrTextLayer.cs new file mode 100644 index 00000000..3f2bc2e7 --- /dev/null +++ b/src/Filee.Engines/Ocr/OcrTextLayer.cs @@ -0,0 +1,373 @@ +// The invisible text of a searchable PDF written by Filee itself (Apple Vision, VisionConverter): each page is the +// picture as it looks, with the recognized words on top in text render mode 3 (neither filled nor stroked), placed and +// stretched over the words of the picture so they can be searched, selected and copied. PdfDocumentReader and +// TesseractConverter.PagesWithText read it like Tesseract's own layer. +// +// The words are written in a font without glyph shapes made here (GlyphlessFont): every UTF-16 unit is one character +// code (Identity-H), drawn as an empty glyph half an em wide, and the ToUnicode map gives each code back as itself, +// so any script works without a real font in the file. + +using System.Globalization; +using System.IO.Compression; +using System.Text; +using PdfSharp.Drawing; +using PdfSharp.Pdf; +using PdfSharp.Pdf.Advanced; + +namespace Filee.Engines.Ocr; + +/// +/// One recognized line. Positions are fractions of the picture (0–1) from its bottom-left corner, as Apple Vision +/// reports them. +/// +/// The line's words, left to right. +internal sealed record OcrLine(double Left, double Bottom, double Width, double Height, IReadOnlyList Words) +{ + /// + /// Splits into words at white space and spreads them over the line by their length: for + /// a line whose words have no boxes of their own. + /// + public static OcrLine Estimate(string text, double left, double bottom, double width, double height) + { + var words = new List(); + var ranges = WordRanges(text); + var letters = ranges.Sum(r => r.Length); + if (letters == 0) + return new OcrLine(left, bottom, width, height, words); + // A space between two words counts as one letter of width. + var unit = width / (letters + ranges.Count - 1); + var x = left; + foreach (var (start, length) in ranges) + { + words.Add(new OcrWord(text.Substring(start, length), x, unit * length)); + x += unit * (length + 1); + } + return new OcrLine(left, bottom, width, height, words); + } + + /// Where each word of starts and how long it is, in UTF-16 units. + public static List<(int Start, int Length)> WordRanges(string text) + { + var ranges = new List<(int, int)>(); + var start = -1; + for (var i = 0; i <= text.Length; i++) + { + var space = i == text.Length || char.IsWhiteSpace(text[i]); + if (!space && start < 0) + start = i; + else if (space && start >= 0) + { + ranges.Add((start, i - start)); + start = -1; + } + } + return ranges; + } +} + +/// One recognized word: its text and where it is across the line (fractions of the picture's width). +internal sealed record OcrWord(string Text, double Left, double Width); + +/// Writes searchable PDFs: pictures as pages, each with its recognized text as an invisible layer. +internal sealed class OcrPdfWriter : IDisposable +{ + /// The font's resource name on every page. + internal const string FontResource = "/FOcr"; + + /// Width of every character code, in thousandths of the font size. + private const int CharacterWidth = 500; + + private readonly PdfDocument _document = new(); + private PdfDictionary? _font; + + public int PageCount => _document.PageCount; + + /// + /// Adds (a JPEG or PNG) as a page of × + /// points with on it as invisible text. + /// + public void AddPage(string picture, double width, double height, IReadOnlyList lines) + { + var page = _document.AddPage(); + page.Width = XUnit.FromPoint(width); + page.Height = XUnit.FromPoint(height); + using (var gfx = XGraphics.FromPdfPage(page)) + using (var image = XImage.FromFile(picture)) + gfx.DrawImage(image, 0, 0, width, height); + var text = TextLayer(lines, width, height); + if (text.Length == 0) + return; + var resources = page.Resources; + if (resources.Elements.GetDictionary("/Font") is not { } fonts) + { + fonts = new PdfDictionary(_document); + resources.Elements["/Font"] = fonts; + } + fonts.Elements.SetReference(FontResource, _font ??= CreateFont()); + page.Contents.AppendContent().CreateStream(Encoding.ASCII.GetBytes(text)); + } + + public void Save(string path) => _document.Save(path); + + public void Dispose() => _document.Dispose(); + + /// + /// The content stream of the invisible text: one font size per line (its height), the baseline at the line's + /// bottom, and each word stretched (Tz) from where it starts to where the next one starts, its trailing space + /// included, so text extraction finds the words and the spaces between them. Empty when there is nothing to write. + /// + internal static string TextLayer(IReadOnlyList lines, double pageWidth, double pageHeight) + { + var content = new StringBuilder(); + foreach (var line in lines) + { + var size = line.Height * pageHeight; + if (size <= 0) + continue; + var baseline = line.Bottom * pageHeight; + for (var i = 0; i < line.Words.Count; i++) + { + var word = line.Words[i]; + if (string.IsNullOrWhiteSpace(word.Text)) + continue; + var x = word.Left * pageWidth; + var last = i == line.Words.Count - 1; + var text = last ? word.Text : word.Text + " "; + var width = word.Width * pageWidth; + if (!last && (line.Words[i + 1].Left - word.Left) * pageWidth is var toNext && toNext > width) + width = toNext; + if (width <= 0) + continue; + var scale = 100 * width / (text.Length * size * CharacterWidth / 1000); + content.Append(CultureInfo.InvariantCulture, $"{FontResource} {N(size)} Tf {N(scale)} Tz 1 0 0 1 {N(x)} {N(baseline)} Tm <"); + foreach (var unit in text) + content.Append(((int)unit).ToString("X4", CultureInfo.InvariantCulture)); + content.Append("> Tj\n"); + } + } + return content.Length == 0 ? "" : "q\nBT\n3 Tr\n" + content + "ET\nQ\n"; + } + + private static string N(double value) => Math.Round(value, 3).ToString("0.###", CultureInfo.InvariantCulture); + + /// The Type 0 font with the glyphless TrueType font in it, once per document. + private PdfDictionary CreateFont() + { + var file = Indirect(GlyphlessFont.Bytes, compress: true); + file.Elements.SetInteger("/Length1", GlyphlessFont.Bytes.Length); + + var descriptor = Indirect(); + descriptor.Elements.SetName("/Type", "/FontDescriptor"); + descriptor.Elements.SetName("/FontName", "/" + GlyphlessFont.Name); + descriptor.Elements.SetInteger("/Flags", 5); // fixed pitch, symbolic + descriptor.Elements["/FontBBox"] = Numbers(0, 0, CharacterWidth, 1000); + descriptor.Elements.SetInteger("/ItalicAngle", 0); + descriptor.Elements.SetInteger("/Ascent", 1000); + descriptor.Elements.SetInteger("/Descent", -1); + descriptor.Elements.SetInteger("/CapHeight", 1000); + descriptor.Elements.SetInteger("/StemV", 80); + descriptor.Elements.SetReference("/FontFile2", file); + + // Every character code shows glyph 1, the empty one. + var map = new byte[2 * 65536]; + for (var i = 1; i < map.Length; i += 2) + map[i] = 1; + var cidToGid = Indirect(map, compress: true); + + var info = new PdfDictionary(_document); + info.Elements["/Registry"] = new PdfString("Adobe"); + info.Elements["/Ordering"] = new PdfString("Identity"); + info.Elements.SetInteger("/Supplement", 0); + + var cidFont = Indirect(); + cidFont.Elements.SetName("/Type", "/Font"); + cidFont.Elements.SetName("/Subtype", "/CIDFontType2"); + cidFont.Elements.SetName("/BaseFont", "/" + GlyphlessFont.Name); + cidFont.Elements["/CIDSystemInfo"] = info; + cidFont.Elements.SetReference("/FontDescriptor", descriptor); + cidFont.Elements.SetInteger("/DW", CharacterWidth); + cidFont.Elements.SetReference("/CIDToGIDMap", cidToGid); + + var toUnicode = Indirect(Encoding.ASCII.GetBytes(ToUnicode), compress: false); + + var font = Indirect(); + font.Elements.SetName("/Type", "/Font"); + font.Elements.SetName("/Subtype", "/Type0"); + font.Elements.SetName("/BaseFont", "/" + GlyphlessFont.Name); + font.Elements.SetName("/Encoding", "/Identity-H"); + var descendants = new PdfArray(_document); + descendants.Elements.Add(cidFont.Reference!); + font.Elements["/DescendantFonts"] = descendants; + font.Elements.SetReference("/ToUnicode", toUnicode); + return font; + } + + /// Each two-byte code is the UTF-16 unit it shows (a character outside the BMP is two codes). + private const string ToUnicode = """ + /CIDInit /ProcSet findresource begin + 12 dict begin + begincmap + /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def + /CMapName /Adobe-Identity-UCS def + /CMapType 2 def + 1 begincodespacerange + <0000> + endcodespacerange + 1 beginbfrange + <0000> <0000> + endbfrange + endcmap + CMapName currentdict /CMap defineresource pop + end + end + """; + + private PdfDictionary Indirect(byte[]? stream = null, bool compress = false) + { + var dictionary = new PdfDictionary(_document); + _document.Internals.AddObject(dictionary); + if (stream is not null) + { + if (compress) + { + using var buffer = new MemoryStream(); + using (var zlib = new ZLibStream(buffer, CompressionLevel.Optimal, leaveOpen: true)) + zlib.Write(stream); + dictionary.CreateStream(buffer.ToArray()); + dictionary.Elements.SetName("/Filter", "/FlateDecode"); + } + else + { + dictionary.CreateStream(stream); + } + } + return dictionary; + } + + private PdfArray Numbers(params int[] values) + { + var array = new PdfArray(_document); + foreach (var value in values) + array.Elements.Add(new PdfInteger(value)); + return array; + } +} + +/// +/// A TrueType font with two empty glyphs, 500 of 1000 units wide: what the invisible text of an OCR layer is set in. +/// Built in code, so there is no font file to ship. +/// +internal static class GlyphlessFont +{ + public const string Name = "FileeGlyphless"; + + public static readonly byte[] Bytes = Build(); + + private static byte[] Build() + { + // Tables in the order of their tags (the directory must be sorted), as 16-bit words (a 32-bit field is two). + var tables = new SortedDictionary(StringComparer.Ordinal) + { + // Version 0, one subtable (Windows, Unicode BMP) at 12: format 4, one segment that maps only 0xFFFF. + ["cmap"] = Words(0, 1, 3, 1, 0, 12, 4, 24, 0, 2, 2, 0, 0, 0xFFFF, 0, 0xFFFF, 1, 0), + ["glyf"] = [], + // Version 1.0, revision 1.0, checksum adjustment (set below), magic number, flags, 1000 units per em, + // created and modified (none), bounding box 0 0 500 1000, style, smallest size, direction, short loca, + // glyph format. + ["head"] = Words(1, 0, 1, 0, 0, 0, 0x5F0F, 0x3CF5, 3, 1000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 500, 1000, 0, 3, 2, 0, 0), + // Version 1.0, ascender 1000, descender and gap 0, widest 500, bearings and extent 0, upright caret, + // reserved, metric format, two metrics. + ["hhea"] = Words(1, 0, 1000, 0, 0, 500, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2), + ["hmtx"] = Words(500, 0, 500, 0), + ["loca"] = Words(0, 0, 0), + // Version 1.0, two glyphs, no points or contours, two zones. + ["maxp"] = Words(1, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0), + ["name"] = Words(0, 0, 6), + // Version 3.0 (no glyph names), upright, no underline, fixed pitch. + ["post"] = Words(3, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0), + }; + + var count = tables.Count; + var power = 1; + var log = 0; + while (power * 2 <= count) + { + power *= 2; + log++; + } + var font = new BigEndian(); + font.U32(0x00010000); + font.U16(count); + font.U16(power * 16); + font.U16(log); + font.U16(count * 16 - power * 16); + var offset = 12 + 16 * count; + var headOffset = 0; + foreach (var (tag, data) in tables) + { + font.Bytes(Encoding.ASCII.GetBytes(tag)); + font.U32(Checksum(data)); + font.U32((uint)offset); + font.U32((uint)data.Length); + if (tag == "head") + headOffset = offset; + offset += Padded(data.Length); + } + foreach (var data in tables.Values) + { + font.Bytes(data); + font.Bytes(new byte[Padded(data.Length) - data.Length]); + } + var bytes = font.ToArray(); + var adjustment = unchecked(0xB1B0AFBA - Checksum(bytes)); + bytes[headOffset + 8] = (byte)(adjustment >> 24); + bytes[headOffset + 9] = (byte)(adjustment >> 16); + bytes[headOffset + 10] = (byte)(adjustment >> 8); + bytes[headOffset + 11] = (byte)adjustment; + return bytes; + } + + private static int Padded(int length) => (length + 3) & ~3; + + private static uint Checksum(byte[] data) + { + uint sum = 0; + for (var i = 0; i < data.Length; i += 4) + { + uint value = 0; + for (var j = 0; j < 4; j++) + value = (value << 8) | (i + j < data.Length ? data[i + j] : 0u); + sum = unchecked(sum + value); + } + return sum; + } + + private static byte[] Words(params int[] values) + { + var writer = new BigEndian(); + foreach (var value in values) + writer.U16(value); + return writer.ToArray(); + } + + private sealed class BigEndian + { + private readonly MemoryStream _stream = new(); + + public void U16(int value) + { + _stream.WriteByte((byte)(value >> 8)); + _stream.WriteByte((byte)value); + } + + public void U32(uint value) + { + U16((int)(value >> 16)); + U16((int)(value & 0xFFFF)); + } + + public void Bytes(byte[] value) => _stream.Write(value); + + public byte[] ToArray() => _stream.ToArray(); + } +} diff --git a/src/Filee.Engines/Ocr/TesseractConverter.cs b/src/Filee.Engines/Ocr/TesseractConverter.cs index f0f37013..cd504ab3 100644 --- a/src/Filee.Engines/Ocr/TesseractConverter.cs +++ b/src/Filee.Engines/Ocr/TesseractConverter.cs @@ -100,7 +100,8 @@ public async Task> ConvertAsync(ConversionStep step, IProg return []; if (step.From == "pdf") { - await RecognizePdfAsync(ocr, step, scratch, output, progress, cancellationToken); + await RecognizePdfAsync(step, scratch, output, Runs, (picture, page, token) => ocr.ToPdfAsync(picture, page, Dpi, token), + progress, cancellationToken); } else { @@ -116,10 +117,15 @@ public async Task> ConvertAsync(ConversionStep step, IProg /// /// The pages without text become recognized scans, the others are copied. A PDF whose pages all have text is - /// copied as it is, with a note. + /// copied as it is, with a note. Also Apple Vision's way (). /// - private static async Task RecognizePdfAsync(Run ocr, ConversionStep step, string scratch, string output, - IProgress? progress, CancellationToken cancellationToken) + /// How many pages are recognized at the same time. + /// + /// Reads the picture of a page (p{n}.png in , at ) and writes it + /// as a searchable one-page PDF (p{n}.pdf there); it gets both names relative to . + /// + internal static async Task RecognizePdfAsync(ConversionStep step, string scratch, string output, int parallel, + Func recognize, IProgress? progress, CancellationToken cancellationToken) { var bytes = await File.ReadAllBytesAsync(step.InputPath, cancellationToken); var hasText = PagesWithText(step.InputPath); @@ -135,7 +141,7 @@ private static async Task RecognizePdfAsync(Run ocr, ConversionStep step, string // Page by page: PDFium draws a page only when one of the Tesseract runs is free, and a page's picture is deleted // as soon as its PDF exists, so a 300-page scan never has more than Runs pictures in the work folder. var options = new RenderOptions(Dpi: Dpi, WithAnnotations: true, WithFormFill: true, BackgroundColor: SKColors.White); - using var free = new SemaphoreSlim(Runs); + using var free = new SemaphoreSlim(parallel); var runs = new List(); var done = 0; try @@ -164,7 +170,7 @@ private static async Task RecognizePdfAsync(Run ocr, ConversionStep step, string { try { - await ocr.ToPdfAsync($"p{page}.png", $"p{page}", Dpi, cancellationToken); + await recognize($"p{page}.png", $"p{page}", cancellationToken); File.Delete(picture); progress?.Report(0.05 + 0.9 * Interlocked.Increment(ref done) / scans.Count); } diff --git a/src/Filee.Engines/Ocr/VisionConverter.cs b/src/Filee.Engines/Ocr/VisionConverter.cs new file mode 100644 index 00000000..4539df00 --- /dev/null +++ b/src/Filee.Engines/Ocr/VisionConverter.cs @@ -0,0 +1,180 @@ +// Text recognition (OCR) with Apple Vision on macOS: the same "Recognize text" step as TesseractConverter (scans and +// pictures → searchable PDF, read on into DOCX, TXT, Markdown and HWPX), with macOS's own recognizer, so a Mac needs no +// download for the languages Vision reads. It has the same OCR edges as Tesseract and is registered before it, so on +// a Mac it is the one the route planner takes; a language Vision doesn't read (VisionLanguages) goes to Tesseract when +// that is installed, and is refused with a hint to install it otherwise. On Windows and Linux it is unavailable. +// +// PDFs go page by page like Tesseract's (TesseractConverter.RecognizePdfAsync: pages with text are copied, scans are +// rendered at 300 dpi); each recognized page is the picture with the words as an invisible text layer (OcrPdfWriter). +// A JPEG picture goes into the PDF as it is; other pictures (and a JPEG turned by its EXIF orientation) as PNG, every +// page of a TIFF. + +using Filee.Core.Conversion; +using ImageMagick; + +namespace Filee.Engines.Ocr; + +/// Scans and pictures → searchable PDF through Apple Vision (macOS). +public sealed class VisionConverter : IConverter +{ + private readonly ITextRecognizer? _recognizer; + private readonly IConverter _tesseract; + + public VisionConverter() + : this(OperatingSystem.IsMacOS() ? new AppleVision() : null, new TesseractConverter()) + { + } + + /// Reads the languages Vision doesn't, when it is installed. + internal VisionConverter(ITextRecognizer? recognizer, IConverter tesseract) + { + _recognizer = recognizer; + _tesseract = tesseract; + } + + /// Vision uses the Neural Engine or the GPU: a few pages at a time keep it busy. + internal static int Runs => Math.Clamp(Environment.ProcessorCount, 1, 2); + + public string Id => "vision"; + public string DisplayName => "Apple Vision"; + + /// One file at a time; its pages are recognized in parallel. + public int MaxParallelism => 1; + + public IReadOnlyList Edges { get; } = + [.. TesseractConverter.ImageSources.Select(f => new ConversionEdge(f, "pdf", Ocr: true)), new("pdf", "pdf", Ocr: true)]; + + public EngineStatus GetStatus() + { + if (_recognizer is null) + return EngineStatus.Unavailable("engine.reason.unsupported_platform"); + if (!_recognizer.IsAvailable) + return EngineStatus.Unavailable("engine.reason.not_installed", "Vision.framework"); + return EngineStatus.Available(string.Join(", ", _recognizer.Languages), $"macOS {Environment.OSVersion.Version}"); + } + + /// + /// True when Apple Vision reads text in (a Tesseract code, PdfOptions.OcrLanguage) on + /// this computer, so that language needs no download. + /// + public bool Reads(string code) => + _recognizer is { IsAvailable: true } recognizer && VisionLanguages.For(code, recognizer.Languages) is not null; + + public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + { + var recognizer = _recognizer is { IsAvailable: true } available ? available + : throw new PlatformNotSupportedException("Apple Vision text recognition runs on macOS only."); + var language = OcrLanguages.Find(step.Preset.Pdf.OcrLanguage) ?? OcrLanguages.Find(OcrLanguages.Default)!; + if (VisionLanguages.For(language.Code, recognizer.Languages) is not { } languages) + { + if (_tesseract.GetStatus().IsAvailable) + return await _tesseract.ConvertAsync(step, progress, cancellationToken); + throw new ConversionRefusedException("error.ocr_needs_tesseract", language.NativeName); + } + + var scratch = Path.Combine(step.WorkDirectory, "vision-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(scratch); + progress?.Report(0.05); + var output = step.Output.Allocate("pdf"); + if (output is null) + return []; + if (step.From == "pdf") + { + await TesseractConverter.RecognizePdfAsync(step, scratch, output, Runs, (picture, page, token) => Task.Run(() => + { + var path = Path.Combine(scratch, picture); + var (width, height) = PixelSize(path); + using var writer = new OcrPdfWriter(); + writer.AddPage(path, Points(width, TesseractConverter.Dpi), Points(height, TesseractConverter.Dpi), + Recognize(recognizer, path, languages, token)); + writer.Save(Path.Combine(scratch, page + ".pdf")); + }, token), progress, cancellationToken); + } + else + { + await Task.Run(() => + { + using var writer = new OcrPdfWriter(); + foreach (var page in Pictures(step.InputPath, step.From, scratch)) + { + cancellationToken.ThrowIfCancellationRequested(); + writer.AddPage(page.Path, Points(page.Width, page.Dpi), Points(page.Height, page.Dpi), + Recognize(recognizer, page.Path, languages, cancellationToken)); + } + writer.Save(output); + }, cancellationToken); + } + progress?.Report(1); + return [output]; + } + + /// + /// Reads one picture. When Vision fails with a second language (English) next to the one asked for, it reads again + /// with that one alone. + /// + internal static IReadOnlyList Recognize(ITextRecognizer recognizer, string picture, IReadOnlyList languages, + CancellationToken cancellationToken) + { + try + { + return recognizer.Recognize(picture, languages, cancellationToken); + } + catch (InvalidOperationException) when (languages.Count > 1 && !cancellationToken.IsCancellationRequested) + { + return recognizer.Recognize(picture, [languages[0]], cancellationToken); + } + } + + /// A picture's pages: where each is, its size in pixels and its resolution. + internal sealed record Picture(string Path, uint Width, uint Height, double Dpi); + + /// + /// The pages of a picture as files PDFsharp embeds: a JPEG as it is (unless EXIF turns it), anything else as PNG + /// in , turned upright; every page of a TIFF, the first frame of anything else. + /// + internal static IEnumerable Pictures(string path, string format, string scratch) + { + if (format == "jpg") + { + using var info = new MagickImage(); + info.Ping(path); + if (info.Orientation is OrientationType.Undefined or OrientationType.TopLeft) + { + yield return new Picture(path, info.Width, info.Height, Dpi(info)); + yield break; + } + } + using var frames = new MagickImageCollection(); + frames.Read(path, new MagickReadSettings { FrameIndex = format == "tiff" ? null : 0u }); + var index = 0; + foreach (var frame in frames) + { + var dpi = Dpi(frame); + frame.AutoOrient(); + var png = Path.Combine(scratch, $"picture-{index++}.png"); + frame.Write(png, MagickFormat.Png); + yield return new Picture(png, frame.Width, frame.Height, dpi); + } + } + + /// + /// The resolution the picture says it has, or 300 dpi when it says none (a scanner's resolution; like + /// Tesseract, which would otherwise assume 70 dpi and make a huge page). + /// + internal static double Dpi(IMagickImage image) + { + var density = image.Density; + if (density.X <= 1) + return TesseractConverter.Dpi; + return density.Units == DensityUnit.PixelsPerCentimeter ? density.X * 2.54 : density.X; + } + + private static double Points(uint pixels, double dpi) => pixels * 72.0 / dpi; + + private static (uint Width, uint Height) PixelSize(string path) + { + using var info = new MagickImage(); + info.Ping(path); + return (info.Width, info.Height); + } +} diff --git a/src/Filee.Engines/Ocr/VisionLanguages.cs b/src/Filee.Engines/Ocr/VisionLanguages.cs new file mode 100644 index 00000000..98c562c2 --- /dev/null +++ b/src/Filee.Engines/Ocr/VisionLanguages.cs @@ -0,0 +1,59 @@ +// Which languages of the preset (PdfOptions.OcrLanguage, a Tesseract code from OcrLanguages) Apple Vision reads, as +// the language identifiers Vision takes. Vision says at run time which it reads (more with each macOS version); a +// language it doesn't is read by Tesseract instead (VisionConverter). + +namespace Filee.Engines.Ocr; + +internal static class VisionLanguages +{ + /// Tesseract code → Vision identifiers it may go by, the usual one first. + private static readonly Dictionary ByCode = new() + { + ["eng"] = ["en-US"], + ["deu"] = ["de-DE"], + ["fra"] = ["fr-FR"], + ["spa"] = ["es-ES"], + ["por"] = ["pt-BR"], + ["ita"] = ["it-IT"], + ["nld"] = ["nl-NL"], + ["pol"] = ["pl-PL"], + ["ces"] = ["cs-CZ"], + ["ron"] = ["ro-RO"], + ["swe"] = ["sv-SE"], + ["dan"] = ["da-DK"], + ["nor"] = ["nb-NO", "no-NO"], + ["tur"] = ["tr-TR"], + ["ind"] = ["id-ID"], + ["msa"] = ["ms-MY"], + ["rus"] = ["ru-RU"], + ["ukr"] = ["uk-UA"], + ["ara"] = ["ar-SA"], + ["tha"] = ["th-TH"], + ["vie"] = ["vi-VT", "vi-VN"], + ["kor"] = ["ko-KR"], + ["jpn"] = ["ja-JP"], + ["chi_sim"] = ["zh-Hans"], + ["chi_tra"] = ["zh-Hant"], + }; + + /// The languages Vision reads on every macOS Filee runs on (13 and later), when it can't be asked. + internal static readonly IReadOnlyList Fallback = + ["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant", "ko-KR", "ja-JP", "ru-RU", "uk-UA"]; + + /// + /// The languages to ask Vision for when the text is in : that language, then English + /// (scans in other languages often have English words in them), or null when Vision doesn't read it. + /// + /// What this Mac's Vision reads (). + public static IReadOnlyList? For(string code, IReadOnlyCollection supported) + { + if (!ByCode.TryGetValue(code, out var candidates)) + return null; + var match = candidates.Select(c => supported.FirstOrDefault(s => string.Equals(s, c, StringComparison.OrdinalIgnoreCase))) + .FirstOrDefault(s => s is not null); + if (match is null) + return null; + var english = supported.FirstOrDefault(s => string.Equals(s, "en-US", StringComparison.OrdinalIgnoreCase)); + return english is null || english == match ? [match] : [match, english]; + } +} diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index 94c08583..130f1ce6 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -1171,6 +1171,10 @@ public void Preset_editor_shows_and_saves_text_recognition(string language) Assert.True(vm.Ocr); Assert.Equal("eng", vm.OcrLanguage.Value); Assert.Equal("eng", vm.OcrLanguageChoices[0].Value); // English first, the others by their own names + // On a Mac Apple Vision reads the common languages, with nothing to download. + Assert.Equal(loc[OperatingSystem.IsMacOS() ? "presets.ocr_hint_macos" : "presets.ocr_hint"], vm.OcrHint); + if (OperatingSystem.IsMacOS()) + Assert.Null(vm.OcrLanguageChoices.Single(l => l.Value == "deu").Detail); var window = new PresetEditorWindow { DataContext = vm, Height = 1400 }; window.Show(); diff --git a/tests/Filee.Engines.Tests/OcrTests.cs b/tests/Filee.Engines.Tests/OcrTests.cs index ea0d514b..3679adde 100644 --- a/tests/Filee.Engines.Tests/OcrTests.cs +++ b/tests/Filee.Engines.Tests/OcrTests.cs @@ -102,9 +102,10 @@ public void An_ocr_layer_a_page_of_text_and_a_drawn_note_are_left_alone() public class OcrTests(EngineFixture fx) : IClassFixture { - private static readonly bool Installed = TesseractConverter.Locate() is not null; + /// On a Mac Apple Vision takes the OCR step (VisionConverter, AppleVisionTests), so these are Tesseract's elsewhere. + private static readonly bool Installed = TesseractConverter.Locate() is not null && !new VisionConverter().GetStatus().IsAvailable; - private const string Skip = "Tesseract is not installed (pwsh build/fetch-engines.ps1 -Only 7zip,tesseract)"; + private const string Skip = "Tesseract is not installed (pwsh build/fetch-engines.ps1 -Only 7zip,tesseract), or Apple Vision reads instead"; private static readonly string? Arial = EngineEnvironment.FirstExisting(@"C:\Windows\Fonts\arial.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf"); diff --git a/tests/Filee.Engines.Tests/VisionOcrTests.cs b/tests/Filee.Engines.Tests/VisionOcrTests.cs new file mode 100644 index 00000000..1e575e92 --- /dev/null +++ b/tests/Filee.Engines.Tests/VisionOcrTests.cs @@ -0,0 +1,457 @@ +// Text recognition with Apple Vision (VisionConverter): the language mapping, the invisible text layer it writes and +// the converter around a stand-in recognizer run everywhere; AppleVisionTests read real pictures on macOS only. + +using System.Collections.Concurrent; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Filee.Engines.Infrastructure; +using Filee.Engines.Ocr; +using ImageMagick; +using ImageMagick.Drawing; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; +using UglyToad.PdfPig.Core; + +namespace Filee.Engines.Tests; + +public class VisionLanguageTests +{ + private static readonly string[] Ventura = [.. VisionLanguages.Fallback]; + + [Fact] + public void A_language_vision_reads_is_asked_for_with_english_next_to_it() + { + Assert.Equal(["en-US"], VisionLanguages.For("eng", Ventura)); + Assert.Equal(["ko-KR", "en-US"], VisionLanguages.For("kor", Ventura)); + Assert.Equal(["zh-Hant", "en-US"], VisionLanguages.For("chi_tra", Ventura)); + Assert.Equal(["DE-de", "en-US"], VisionLanguages.For("deu", ["DE-de", "en-US"])); // as this Mac spells it + } + + [Fact] + public void A_language_this_mac_has_no_model_for_is_left_to_tesseract() + { + Assert.Null(VisionLanguages.For("hin", Ventura)); // no Devanagari in Vision + Assert.Null(VisionLanguages.For("tha", Ventura)); // Thai came with a later macOS + Assert.Equal(["th-TH", "en-US"], VisionLanguages.For("tha", [.. Ventura, "th-TH"])); + Assert.Equal(["no-NO", "en-US"], VisionLanguages.For("nor", [.. Ventura, "no-NO"])); + Assert.Equal(["fr-FR"], VisionLanguages.For("fra", ["fr-FR"])); + } + + [Fact] + public void Every_mapped_language_is_one_filee_offers() + { + foreach (var code in new[] { "eng", "deu", "fra", "spa", "por", "ita", "nld", "pol", "ces", "ron", "swe", "dan", "nor", "tur", + "ind", "msa", "rus", "ukr", "ara", "tha", "vie", "kor", "jpn", "chi_sim", "chi_tra" }) + Assert.NotNull(OcrLanguages.Find(code)); + } +} + +public class OcrTextLayerTests(EngineFixture fx) : IClassFixture +{ + [Fact] + public void Words_are_found_at_spaces_and_spread_over_the_line_when_they_have_no_boxes() + { + Assert.Equal([(0, 5), (7, 5), (13, 1)], OcrLine.WordRanges("Filee reads\tA ")); + var line = OcrLine.Estimate("ab cd", 0.1, 0.5, 0.5, 0.1); + Assert.Equal(["ab", "cd"], line.Words.Select(w => w.Text)); + Assert.Equal(0.1, line.Words[0].Left, 6); + Assert.Equal(0.2, line.Words[0].Width, 6); // 5 letters' width, the space included + Assert.Equal(0.4, line.Words[1].Left, 6); + Assert.Empty(OcrLine.Estimate(" ", 0, 0, 1, 1).Words); + } + + [Fact] + public void The_glyphless_font_is_a_well_formed_truetype_font() + { + var font = GlyphlessFont.Bytes; + Assert.Equal(0x00010000u, BigEndian(font, 0)); + uint sum = 0; + for (var i = 0; i < font.Length; i += 4) + sum = unchecked(sum + BigEndian(font, i)); + Assert.Equal(0xB1B0AFBAu, sum); // the whole font sums to the magic number (head.checkSumAdjustment) + var tags = Enumerable.Range(0, (font[4] << 8) | font[5]).Select(i => System.Text.Encoding.ASCII.GetString(font, 12 + 16 * i, 4)).ToList(); + Assert.Equal(tags.Order(StringComparer.Ordinal), tags); + Assert.Contains("head", tags); + Assert.Contains("hmtx", tags); + using var typeface = SkiaSharp.SKTypeface.FromData(SkiaSharp.SKData.CreateCopy(font)); + Assert.NotNull(typeface); // the system's font loader takes it + Assert.Equal(2, typeface.GlyphCount); + } + + private static uint BigEndian(byte[] bytes, int at) => + (uint)((at < bytes.Length ? bytes[at] : 0) << 24 | (at + 1 < bytes.Length ? bytes[at + 1] : 0) << 16 + | (at + 2 < bytes.Length ? bytes[at + 2] : 0) << 8 | (at + 3 < bytes.Length ? bytes[at + 3] : 0)); + + [Fact] + public void The_recognized_words_are_invisible_text_where_the_picture_shows_them() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "page.png", 1240, 1754); + var pdf = Path.Combine(folder, "page.pdf"); + using (var writer = new OcrPdfWriter()) + { + // An A4 page at 150 dpi: 595.2 × 841.92 points. + writer.AddPage(picture, 595.2, 841.92, + [ + new OcrLine(0.1, 0.8, 0.6, 0.02, [new OcrWord("Quarterly", 0.1, 0.2), new OcrWord("report", 0.35, 0.15)]), + OcrLine.Estimate("검색 가능한 문서 Документ", 0.1, 0.5, 0.7, 0.03), + ]); + writer.Save(pdf); + } + + using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); + var page = document.GetPage(1); + Assert.Equal(595.2, page.Width, 1); + Assert.Equal(841.92, page.Height, 1); + var words = page.GetWords().ToList(); + Assert.Equal(["Quarterly", "report", "검색", "가능한", "문서", "Документ"], words.Select(w => w.Text)); + Assert.All(page.Letters.Where(l => !string.IsNullOrWhiteSpace(l.Value)), l => Assert.Equal(TextRenderingMode.Neither, l.RenderingMode)); + var quarterly = words[0]; + Assert.Equal(0.1 * 595.2, quarterly.BoundingBox.Left, 0); + Assert.Equal(0.8 * 841.92, quarterly.Letters[0].StartBaseLine.Y, 0); + Assert.InRange(quarterly.BoundingBox.Right, 0.29 * 595.2, 0.35 * 595.2 + 1); // stretched up to the next word + Assert.InRange(words[1].BoundingBox.Left, 0.35 * 595.2 - 1, 0.35 * 595.2 + 1); + Assert.Single(page.GetImages()); + + // A page with an OCR layer counts as having text: OCR leaves it alone the next time. + Assert.Equal([true], TesseractConverter.PagesWithText(pdf)); + } + + [Fact] + public async Task A_page_with_the_layer_reads_into_txt() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "scan.png", 600, 800); + var pdf = Path.Combine(folder, "scan.pdf"); + using (var writer = new OcrPdfWriter()) + { + writer.AddPage(picture, 144, 192, [OcrLine.Estimate("The quarterly report is ready", 0.1, 0.7, 0.8, 0.04)]); + writer.Save(pdf); + } + + var job = await fx.ConvertAsync([pdf], new Preset { TargetFormat = "txt" }); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var text = await File.ReadAllTextAsync(job.Outputs.Single(), TestContext.Current.CancellationToken); + Assert.Contains("The quarterly report is ready", text); + } +} + +public class VisionConverterTests(EngineFixture fx) : IClassFixture +{ + private static Preset Ocr(string language = "eng") => new() { TargetFormat = "pdf", Pdf = { Ocr = true, OcrLanguage = language } }; + + private static ConversionStep Step(string input, string from, string output, Preset preset, Action? notice = null) => + new(input, from, "pdf", preset, new HwpxAssert.FixedPath(output), Path.GetDirectoryName(output)!, Notice: notice); + + private static string PageText(string pdf, int page) + { + using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); + return string.Join(" ", document.GetPage(page).GetWords().Select(w => w.Text)); + } + + [Fact] + public void Off_a_mac_it_is_unavailable_and_only_vision_takes_its_place_on_one() + { + var off = new VisionConverter(null, new VisionFakes.Tesseract(available: true)); + Assert.Equal("engine.reason.unsupported_platform", off.GetStatus().ReasonKey); + Assert.False(off.Reads("eng")); + + var mac = new VisionConverter(new VisionFakes.Recognizer(), new VisionFakes.Tesseract(available: true)); + Assert.True(mac.GetStatus().IsAvailable); + Assert.True(mac.Reads("kor")); + Assert.False(mac.Reads("hin")); + Assert.Equal(OperatingSystem.IsMacOS(), new VisionConverter().GetStatus().IsAvailable); + } + + [Fact] + public void On_a_mac_vision_takes_the_ocr_step_before_tesseract() + { + var all = EngineRegistry.CreateAll(new EngineEnvironment(fx.NewFolder())); + var ids = all.Select(c => c.Id).ToList(); + Assert.True(ids.IndexOf("vision") < ids.IndexOf("tesseract")); + + var vision = new VisionConverter(new VisionFakes.Recognizer(), new VisionFakes.Tesseract(available: true)); + var tesseract = new TesseractConverter(); + var catalog = new ConverterCatalog([vision, tesseract]) { Priority = new AppSettings().EnginePriority }; + var planner = new RoutePlanner(catalog.All, _ => 9); + + Assert.Equal("vision", planner.Plan("pdf", "pdf", ocr: true)!.Steps.Single().Converter.Id); + Assert.Equal("vision", planner.Plan("jpg", "pdf", ocr: true)!.Steps.Single().Converter.Id); + Assert.Equal("tesseract", new RoutePlanner([tesseract]).Plan("png", "pdf", ocr: true)!.Steps.Single().Converter.Id); + } + + [Fact] + public async Task A_picture_becomes_a_page_of_its_size_with_the_text_on_it() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "letter.jpg", 1200, 600, dpi: 150); + var recognizer = new VisionFakes.Recognizer(); + var output = Path.Combine(folder, "letter.pdf"); + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(picture, "jpg", output, Ocr("deu")), null, TestContext.Current.CancellationToken); + + Assert.Equal(["de-DE", "en-US"], Assert.Single(recognizer.Calls).Languages); + Assert.Equal(picture, recognizer.Calls.Single().Picture); // a JPEG goes in as it is + using var document = UglyToad.PdfPig.PdfDocument.Open(output); + Assert.Equal(576, document.GetPage(1).Width, 1); // 1200 px at 150 dpi = 8 inches + Assert.Equal(VisionFakes.Text, PageText(output, 1)); + } + + [Fact] + public async Task Every_page_of_a_tiff_is_recognized() + { + var folder = fx.NewFolder(); + var tiff = EngineFixture.MakeMultiPageTiff(folder, 3); + var recognizer = new VisionFakes.Recognizer(); + var output = Path.Combine(folder, "pages.pdf"); + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(tiff, "tiff", output, Ocr()), null, TestContext.Current.CancellationToken); + + Assert.Equal(3, recognizer.Calls.Count); + Assert.All(recognizer.Calls, c => Assert.EndsWith(".png", c.Picture)); + using var document = PdfReader.Open(output, PdfDocumentOpenMode.Import); + Assert.Equal(3, document.PageCount); + } + + [Fact] + public async Task Only_the_scanned_pages_of_a_pdf_are_recognized() + { + var folder = fx.NewFolder(); + var scan = (await fx.ConvertAsync([VisionFakes.Picture(folder, "scan.png", 600, 800)], new Preset { TargetFormat = "pdf" })).Outputs.Single(); + var searchable = Path.Combine(folder, "searchable.pdf"); + using (var writer = new OcrPdfWriter()) + { + writer.AddPage(VisionFakes.Picture(folder, "done.png", 600, 800), 144, 192, [OcrLine.Estimate("Already searchable", 0.1, 0.5, 0.8, 0.05)]); + writer.Save(searchable); + } + var mixed = Path.Combine(folder, "mixed.pdf"); + using (var document = new PdfDocument()) + { + foreach (var part in new[] { searchable, scan }) + { + using var source = PdfReader.Open(part, PdfDocumentOpenMode.Import); + document.AddPage(source.Pages[0]); + } + document.Save(mixed); + } + var recognizer = new VisionFakes.Recognizer(); + var output = Path.Combine(folder, "out.pdf"); + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(mixed, "pdf", output, Ocr()), null, TestContext.Current.CancellationToken); + + Assert.Single(recognizer.Calls); + Assert.Equal("Already searchable", PageText(output, 1)); + Assert.Equal(VisionFakes.Text, PageText(output, 2)); + using var result = UglyToad.PdfPig.PdfDocument.Open(output); + using var original = UglyToad.PdfPig.PdfDocument.Open(scan); + Assert.Equal(original.GetPage(1).Width, result.GetPage(2).Width, 0); // the page keeps its size + } + + [Fact] + public async Task A_pdf_with_text_on_every_page_is_copied_with_a_note() + { + var folder = fx.NewFolder(); + var searchable = Path.Combine(folder, "searchable.pdf"); + using (var writer = new OcrPdfWriter()) + { + writer.AddPage(VisionFakes.Picture(folder, "done.png", 600, 800), 144, 192, [OcrLine.Estimate("Already searchable", 0.1, 0.5, 0.8, 0.05)]); + writer.Save(searchable); + } + var recognizer = new VisionFakes.Recognizer(); + var output = Path.Combine(folder, "out.pdf"); + string? notice = null; + + await new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(searchable, "pdf", output, Ocr(), n => notice = n), null, TestContext.Current.CancellationToken); + + Assert.Empty(recognizer.Calls); + Assert.Equal("notice.ocr_has_text", notice); + } + + [Fact] + public async Task A_language_vision_doesnt_read_goes_to_tesseract_or_asks_for_it() + { + var folder = fx.NewFolder(); + var picture = VisionFakes.Picture(folder, "hindi.png", 300, 200); + var recognizer = new VisionFakes.Recognizer(); + var installed = new VisionFakes.Tesseract(available: true); + + var written = await new VisionConverter(recognizer, installed) + .ConvertAsync(Step(picture, "png", Path.Combine(folder, "a.pdf"), Ocr("hin")), null, TestContext.Current.CancellationToken); + + Assert.Equal(["tesseract"], written); + Assert.Equal(1, installed.Conversions); + Assert.Empty(recognizer.Calls); + + var refused = await Assert.ThrowsAsync(() => new VisionConverter(recognizer, new VisionFakes.Tesseract(available: false)) + .ConvertAsync(Step(picture, "png", Path.Combine(folder, "b.pdf"), Ocr("hin")), null, TestContext.Current.CancellationToken)); + Assert.Equal("error.ocr_needs_tesseract", refused.ErrorKey); + Assert.Equal(OcrLanguages.Find("hin")!.NativeName, refused.Detail); + } + + [Fact] + public void When_vision_refuses_two_languages_it_reads_the_first_alone() + { + var recognizer = new VisionFakes.Recognizer { FailWithSeveralLanguages = true }; + + var lines = VisionConverter.Recognize(recognizer, "page.png", ["ko-KR", "en-US"], TestContext.Current.CancellationToken); + + Assert.Single(lines); + Assert.Equal(2, recognizer.Calls.Count); + Assert.Equal(["ko-KR", "en-US"], recognizer.Calls[0].Languages); + Assert.Equal(["ko-KR"], recognizer.Calls[1].Languages); + } +} + +/// Apple Vision itself: only on macOS (CI's macOS jobs run these on Apple silicon and on Intel). +public class AppleVisionTests(EngineFixture fx) : IClassFixture +{ + private const string Skip = "Apple Vision runs on macOS only"; + + /// Black text on white at 300 dpi, as a scanner writes it, in a font macOS has. + private static string Scan(string folder, string name, string font, params string[] lines) + { + using var image = new MagickImage(MagickColors.White, 2480, 800) { Density = new Density(300) }; + var drawables = new Drawables().FontPointSize(32).FillColor(MagickColors.Black).Font(font); + for (var i = 0; i < lines.Length; i++) + drawables.Text(150, 240 + i * 200, lines[i]); + drawables.Draw(image); + var path = Path.Combine(folder, name); + image.Write(path); + return path; + } + + private const string Helvetica = "/System/Library/Fonts/Helvetica.ttc"; + private const string Gothic = "/System/Library/Fonts/AppleSDGothicNeo.ttc"; + + [Fact] + public void Vision_reads_the_lines_and_words_of_a_picture() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip(Skip); + return; + } + var vision = new AppleVision(); + Assert.True(vision.IsAvailable); + Assert.Contains("en-US", vision.Languages); + Assert.Contains("ko-KR", vision.Languages); + var scan = Scan(fx.NewFolder(), "letter.png", Helvetica, "Filee reads scanned pages", "into searchable documents"); + + var lines = vision.Recognize(scan, ["en-US"], TestContext.Current.CancellationToken); + + Assert.Equal(2, lines.Count); + var first = lines.Single(l => l.Words.Any(w => w.Text == "Filee")); + var second = lines.Single(l => l != first); + Assert.Equal(["Filee", "reads", "scanned", "pages"], first.Words.Select(w => w.Text)); + Assert.True(first.Bottom > second.Bottom); // measured from the bottom of the picture + foreach (var line in lines) + { + Assert.InRange(line.Height, 0.01, 0.2); + for (var i = 0; i < line.Words.Count; i++) + { + var word = line.Words[i]; + Assert.InRange(word.Left, line.Left - 0.01, line.Left + line.Width); + Assert.InRange(word.Width, 0.005, line.Width + 0.01); + if (i > 0) + Assert.True(word.Left > line.Words[i - 1].Left); + } + } + } + + [Fact] + public async Task A_picture_and_a_scanned_pdf_become_searchable_with_vision() + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), Skip); + var folder = fx.NewFolder(); + var picture = Scan(folder, "편지.png", Helvetica, "Filee reads scanned pages"); + var scanPdf = (await fx.ConvertAsync([Scan(folder, "Скан.png", Helvetica, "The quarterly report is ready")], new Preset { TargetFormat = "pdf" })).Outputs.Single(); + + var job = await fx.ConvertAsync([picture, scanPdf], new Preset { TargetFormat = "pdf", Pdf = { Ocr = true } }); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + Assert.Contains("scanned", Text(job.Outputs.Single(o => Path.GetFileName(o).StartsWith("편지", StringComparison.Ordinal)))); + Assert.Contains("quarterly", Text(job.Outputs.Single(o => Path.GetFileName(o).StartsWith("Скан", StringComparison.Ordinal)))); + } + + [Fact] + public async Task Korean_text_is_read_into_txt() + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), Skip); + var folder = fx.NewFolder(); + var scan = Scan(folder, "scan.png", Gothic, "분기 보고서가 준비되었습니다"); + + var job = await fx.ConvertAsync([scan], new Preset { TargetFormat = "txt", Pdf = { Ocr = true, OcrLanguage = "kor" } }); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var text = await File.ReadAllTextAsync(job.Outputs.Single(), TestContext.Current.CancellationToken); + Assert.Contains("보고서", text); + } + + private static string Text(string pdf) + { + using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); + return string.Join(" ", document.GetPages().SelectMany(p => p.GetWords()).Select(w => w.Text)); + } +} + +/// Stand-ins for Apple Vision and Tesseract. +internal static class VisionFakes +{ + public const string Text = "Filee reads scanned pages"; + + /// A grey picture of the given size and resolution. + public static string Picture(string folder, string name, uint width, uint height, int dpi = 300) + { + using var image = new MagickImage(MagickColors.LightGray, width, height) { Density = new Density(dpi) }; + var path = Path.Combine(folder, name); + image.Write(path); + return path; + } + + /// Finds on every picture and reads the languages Vision reads on macOS 13. + public sealed class Recognizer : ITextRecognizer + { + public ConcurrentQueue<(string Picture, IReadOnlyList Languages)> CallQueue { get; } = new(); + + public IReadOnlyList<(string Picture, IReadOnlyList Languages)> Calls => [.. CallQueue]; + + /// Fails like Vision on a combination of languages it doesn't take. + public bool FailWithSeveralLanguages { get; init; } + + public bool IsAvailable => true; + + public IReadOnlyCollection Languages => VisionLanguages.Fallback; + + public IReadOnlyList Recognize(string picture, IReadOnlyList languages, CancellationToken cancellationToken) + { + CallQueue.Enqueue((picture, languages)); + if (FailWithSeveralLanguages && languages.Count > 1) + throw new InvalidOperationException("Apple Vision couldn't read the picture."); + return [OcrLine.Estimate(Text, 0.1, 0.6, 0.8, 0.05)]; + } + } + + /// A Tesseract that is or isn't installed and only counts its conversions. + public sealed class Tesseract(bool available) : IConverter + { + public int Conversions { get; private set; } + public string Id => "tesseract"; + public string DisplayName => "Tesseract OCR"; + public int MaxParallelism => 1; + public IReadOnlyList Edges => []; + + public EngineStatus GetStatus() => available ? EngineStatus.Available() : EngineStatus.Unavailable("engine.reason.not_installed"); + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + { + Conversions++; + return Task.FromResult>(["tesseract"]); + } + } +}