From 48a01a399251badcfa9e673cdb9d55cddb99e0bd Mon Sep 17 00:00:00 2001 From: Gres Date: Wed, 8 Apr 2020 23:17:23 +0300 Subject: [PATCH] Add auto correction with hunspell --- screen-translator.pro | 6 +- share/ci/get_hunspell.py | 118 ++++++++++++++ share/translations/screentranslator_ru.ts | 124 +++++++++----- share/updates/hunspell.py | 101 ++++++++++++ src/capture/capturearea.cpp | 2 + src/capture/capturearea.h | 1 + src/capture/captureareaeditor.cpp | 6 + src/capture/captureareaeditor.h | 1 + src/correct/corrector.cpp | 43 ++++- src/correct/corrector.h | 13 +- src/correct/correctorworker.cpp | 66 ++++++++ src/correct/correctorworker.h | 32 ++++ src/correct/hunspellcorrector.cpp | 169 +++++++++++++++++++ src/correct/hunspellcorrector.h | 26 +++ src/manager.cpp | 3 + src/settings.cpp | 4 + src/settings.h | 2 + src/settingseditor.cpp | 4 + src/settingseditor.ui | 41 +++-- src/task.h | 2 + updates.json | 190 ++++++++++++++++++++++ 21 files changed, 899 insertions(+), 55 deletions(-) create mode 100644 share/ci/get_hunspell.py create mode 100644 share/updates/hunspell.py create mode 100644 src/correct/correctorworker.cpp create mode 100644 src/correct/correctorworker.h create mode 100644 src/correct/hunspellcorrector.cpp create mode 100644 src/correct/hunspellcorrector.h diff --git a/screen-translator.pro b/screen-translator.pro index d38cc72..cee762b 100644 --- a/screen-translator.pro +++ b/screen-translator.pro @@ -8,7 +8,7 @@ DEPS_DIR=$$(ST_DEPS_DIR) isEmpty(DEPS_DIR):DEPS_DIR=$$PWD/../deps INCLUDEPATH += $$DEPS_DIR/include LIBS += -L$$DEPS_DIR/lib -LIBS += -ltesseract -lleptonica +LIBS += -ltesseract -lleptonica -lhunspell win32{ LIBS += -lUser32 @@ -39,6 +39,8 @@ HEADERS += \ src/capture/capturer.h \ src/commonmodels.h \ src/correct/corrector.h \ + src/correct/correctorworker.h \ + src/correct/hunspellcorrector.h \ src/languagecodes.h \ src/manager.h \ src/ocr/recognizer.h \ @@ -72,6 +74,8 @@ SOURCES += \ src/capture/capturer.cpp \ src/commonmodels.cpp \ src/correct/corrector.cpp \ + src/correct/correctorworker.cpp \ + src/correct/hunspellcorrector.cpp \ src/languagecodes.cpp \ src/main.cpp \ src/manager.cpp \ diff --git a/share/ci/get_hunspell.py b/share/ci/get_hunspell.py new file mode 100644 index 0000000..b18b436 --- /dev/null +++ b/share/ci/get_hunspell.py @@ -0,0 +1,118 @@ +import common as c +from config import bitness, msvc_version, build_dir, dependencies_dir +import os +import platform + +c.print('>> Installing hunspell') + +install_dir = dependencies_dir +url = 'https://github.com/hunspell/hunspell/files/2573619/hunspell-1.7.0.tar.gz' +required_version = '1.7.0' + + +def check_existing(): + if platform.system() == "Windows": + dll = install_dir + '/bin/hunspell.dll' + lib = install_dir + '/lib/hunspell.lib' + if not os.path.exists(dll) or not os.path.exists(lib): + return False + elif platform.system() == "Darwin": + lib = install_dir + '/lib/libhunspell.1.7.0.dylib' + if not os.path.exists(lib): + return False + c.symlink(lib, install_dir + '/lib/libhunspell.dylib') + else: + lib = install_dir + '/lib/libhunspell-1.7.so' + if not os.path.exists(lib): + return False + c.symlink(lib, install_dir + '/lib/libhunspell.so') + + includes_path = install_dir + '/include/hunspell' + if len(c.get_folder_files(includes_path)) == 0: + return False + + version_file = install_dir + '/lib/pkgconfig/hunspell.pc' + if not os.path.exists(version_file): + return False + + with open(version_file, 'rt') as f: + lines = f.readlines() + for l in lines: + if not l.startswith('Version'): + continue + existing_version = l[9:14] # Version: 1.7.0 + if existing_version != required_version: + return False + break + return True + + +if check_existing(): + c.print('>> Using cached') + exit(0) + +archive = os.path.basename(url) +c.download(url, archive) + +src_dir = os.path.abspath('hunspell_src') +c.extract(archive, '.') +c.symlink(c.get_archive_top_dir(archive), src_dir) + +c.ensure_got_path(install_dir) + +c.recreate_dir(build_dir) +os.chdir(build_dir) + +c.set_make_threaded() + +if platform.system() != "Windows": + c.run('autoreconf -i {}'.format(src_dir)) + c.run('{}/configure --prefix={}'.format(src_dir, install_dir)) + c.run('make') + c.run('make install') +else: + lib_src = os.path.join(src_dir, 'src', 'hunspell') + sources = [] + with os.scandir(lib_src) as it: + for f in it: + if not f.is_file() or not f.name.endswith('.cxx'): + continue + sources.append('${SRC_DIR}/' + f.name) + + headers = ['${SRC_DIR}/atypes.hxx', '${SRC_DIR}/hunspell.h', '${SRC_DIR}/hunspell.hxx', + '${SRC_DIR}/hunvisapi.h', '${SRC_DIR}/w_char.hxx'] + + cmake_file = os.path.join(build_dir, 'CMakeLists.txt') + with open(cmake_file, 'w') as f: + f.write('project(hunspell)\n') + f.write('cmake_minimum_required(VERSION 3.11)\n') + f.write('set(SRC_DIR "{}")\n'.format(lib_src).replace('\\', '/')) + f.write('\n') + f.write('add_library(hunspell SHARED {})\n'.format(' '.join(sources))) + f.write('\n') + f.write('add_compile_definitions(HAVE_CONFIG_H _WIN32 BUILDING_LIBHUNSPELL)\n') + f.write('\n') + f.write('install(FILES {} \ +DESTINATION include/hunspell)\n'.format(' '.join(headers))) + f.write('\n') + f.write('install(TARGETS hunspell \ +RUNTIME DESTINATION bin LIBRARY DESTINATION lib ARCHIVE DESTINATION lib)\n') + f.write('\n') + f.write('set(prefix "${CMAKE_INSTALL_PREFIX}")\n') + f.write('set(VERSION "{}")\n'.format(required_version)) + f.write('configure_file({}/hunspell.pc.in \ +${{CMAKE_CURRENT_BINARY_DIR}}/hunspell.pc @ONLY)\n'.format(src_dir.replace('\\', '/'))) + f.write('install(FILES ${CMAKE_CURRENT_BINARY_DIR}/hunspell.pc \ +DESTINATION lib/pkgconfig)\n') + + env_cmd = c.get_msvc_env_cmd(bitness=bitness, msvc_version=msvc_version) + c.apply_cmd_env(env_cmd) + cmake_args = '"{}" -DCMAKE_INSTALL_PREFIX="{}" {}'.format( + build_dir, install_dir, c.get_cmake_arch_args(bitness=bitness)) + c.run('cmake {}'.format(cmake_args)) + c.run('cmake --build . --config Release --verbose') + c.run('cmake --build . --target install --config Release') + +if not check_existing(): # create links + c.print('>> Build failed') + exit(1) diff --git a/share/translations/screentranslator_ru.ts b/share/translations/screentranslator_ru.ts index 73871f6..200e201 100644 --- a/share/translations/screentranslator_ru.ts +++ b/share/translations/screentranslator_ru.ts @@ -14,12 +14,17 @@ Сохранить (можно будет захватывать горячими клавишами) - + + Use auto corrections + Использовать автокоррекцию + + + Recognize: Распознать: - + @@ -50,6 +55,14 @@ Ctrl - продолжить выделять Отменить + + CorrectorWorker + + + Failed to init hunspell engine: %1 + Ошибка инициализации hunspell: %1 + + QObject @@ -589,38 +602,43 @@ Ctrl - продолжить выделять + hunspell + hunspell + + + translators перевод - + Screen translator started Экранный переводчик запущен - + Update completed Обновление завершено - + Updates available Доступны обновления - + Current version might be outdated. Check for updates to silence this warning Текущая версия может быть устаревшей. Проверьте обновления, чтобы отключить это сообщение - + Failed to set log file: %1 Ошибка установки лог-файла: %1 - + Started logging to file: %1 Начата запись в лог-файл: %1 @@ -630,7 +648,7 @@ Check for updates to silence this warning <p>Version: %1</p> <p>Author: Gres (<a href="mailto:%2">%2</a>)</p> <p>Issues: <a href="%3">%3</a></p> - <p>Инструмент оптического распознавания текста (OCR) и перевода</p> + <p>Инструмент оптического распознавания текста (OCR) и перевода</p> <p>Версия: %1</p> <p>Автор: Gres (<a href="mailto:%2">%2</a>)</p> <p>Поддержка: <a href="%3">%3</a></p> @@ -650,6 +668,20 @@ Check for updates to silence this warning Failed to recognize text or no text selected Ошибка распознавания текста или нет текста в выделенной зоне + + + Hunspell path not exists +%1 + Путь к словарям hunspell не существует +%1 + + + + No .aff or .dic files for hunspell +in %1 + Нет .aff или .dic файлов для hunspell +в %1 + RecognizeWorker @@ -790,17 +822,37 @@ Check for updates to silence this warning сохранять пароль (небезопасно) - + + User substitutions + Пользовательская коррекция + + + + Use auto corrections (hunspell) + Использовать автокоррекцию (hunspell) + + + + Use user substitutions + Использовать пользовательскую коррекцию + + + + Hunspell dictionaries path: + Путь к словарям Hunspell: + + + Language: Язык: - + secs сек - + Ignore SSL errors Игнорировать ошибки SSL @@ -840,102 +892,92 @@ Check for updates to silence this warning Путь к языкам (tessdata): - - Substitutions - Замены - - - - Substitute recognized text - Заменять распознанный текст - - - + Translators path: Путь к переводчикам: - + Translators Переводчики - + Result window Окно результата - + Font: Шрифт: - + Font size: Размер шрифта: - + Font color: Цвет шрифта: - + Background: Фон: - + Show image Показывать изображение - + Show recognized Показывать распознанное - + Update check interval (days): Интервал проверки обновления (дней): - + 0 - disabled - отключено - + Apply updates Применить изменения - + Translate text Переводить текст - + Single translator timeout: Переходить к следующему переводчику после: - + Result type Тип результата - + Tray Трей - + Window Окно - + Check now Проверить сейчас @@ -1000,7 +1042,7 @@ Check for updates to silence this warning Текст для проверки - + Portable changed. Apply settings first Portable режиме изменени. Сначала применить настройки diff --git a/share/updates/hunspell.py b/share/updates/hunspell.py new file mode 100644 index 0000000..5bae2bf --- /dev/null +++ b/share/updates/hunspell.py @@ -0,0 +1,101 @@ +import sys +import os +import subprocess +import re + + +def parse_language_names(): + root = os.path.abspath(os.path.basename(__file__) + '/../../..') + lines = [] + with open(root + '/src/languagecodes.cpp', 'r') as d: + lines = d.readlines() + result = {} + for line in lines: + if line.startswith('//'): + continue + all = re.findall(r'"(.*?)"', line) + if len(all) != 6: + continue + result[all[2]] = all[5] + return result + + +if len(sys.argv) < 2: + print("Usage:", sys.argv[0], " []") + exit(1) + +dict_dir = sys.argv[1] + +download_url = "https://cgit.freedesktop.org/libreoffice/dictionaries/plain" +if len(sys.argv) > 2: + download_url = sys.argv[2] + +language_names = parse_language_names() + +preferred = ['sr.aff', 'sv_FI.aff', + 'en_US.aff', 'de_DE_frami.aff', 'nb_NO.aff'] + +files = {} +it = os.scandir(dict_dir) +for d in it: + if not d.is_dir(): + continue + + lang = d.name + if '_' in lang: + lang = lang[0:lang.index('_')] + + affs = [] + fit = os.scandir(os.path.join(dict_dir, d.name)) + for f in fit: + if not f.is_file or not f.name.endswith('.aff'): + continue + affs.append(f.name) + + aff = '' + if len(affs) == 0: + continue + if len(affs) == 1: + aff = affs[0] + else: + for p in preferred: + if p in affs: + aff = p + break + + if len(aff) == 0: + print('no aff for', lang, affs) + continue + + aff = os.path.join(d.name, aff) + dic = aff[:aff.rindex('.')] + '.dic' + if not os.path.exists(os.path.join(dict_dir, dic)): + print('no dic exists', dic) + + files[lang] = [aff, dic] + + +print(',"hunspell": {') +comma = '' +unknown_names = [] +for lang, file_names in files.items(): + if not lang in language_names: + unknown_names.append(lang) + continue + lang_name = language_names[lang] + print(' {}"{}":{{"files":['.format(comma, lang_name)) + comma = ', ' + lang_comma = '' + for file_name in file_names: + git_cmd = ['git', 'log', '-1', '--pretty=format:%cI', file_name] + date = subprocess.run(git_cmd, cwd=dict_dir, universal_newlines=True, + stdout=subprocess.PIPE, check=True).stdout + size = os.path.getsize(os.path.join(dict_dir, file_name)) + installed = lang + file_name[file_name.index('/'):] + print(' {}{{"url":"{}/{}", "path":"$hunspell$/{}", "date":"{}", "size":{}}}'.format( + lang_comma, download_url, file_name, installed, date, size)) + lang_comma = ',' + print(' ]}') +print('}') + +print('unknown names', unknown_names) diff --git a/src/capture/capturearea.cpp b/src/capture/capturearea.cpp index 4cc158b..01a7fa3 100644 --- a/src/capture/capturearea.cpp +++ b/src/capture/capturearea.cpp @@ -5,6 +5,7 @@ CaptureArea::CaptureArea(const QRect &rect, const Settings &settings) : rect_(rect) , doTranslation_(settings.doTranslation) + , useHunspell_(settings.useHunspell) , sourceLanguage_(settings.sourceLanguage) , targetLanguage_(settings.targetLanguage) , translators_(settings.translators) @@ -18,6 +19,7 @@ TaskPtr CaptureArea::task(const QPixmap &pixmap) const auto task = std::make_shared(); task->generation = generation_; + task->useHunspell = useHunspell_; task->captured = pixmap.copy(rect_); task->capturePoint = rect_.topLeft(); task->sourceLanguage = sourceLanguage_; diff --git a/src/capture/capturearea.h b/src/capture/capturearea.h index d303520..a1b5a21 100644 --- a/src/capture/capturearea.h +++ b/src/capture/capturearea.h @@ -28,6 +28,7 @@ private: QRect rect_; bool doTranslation_; bool isLocked_{false}; + bool useHunspell_{false}; LanguageId sourceLanguage_; LanguageId targetLanguage_; QStringList translators_; diff --git a/src/capture/captureareaeditor.cpp b/src/capture/captureareaeditor.cpp index 52f9cb9..b7af0fc 100644 --- a/src/capture/captureareaeditor.cpp +++ b/src/capture/captureareaeditor.cpp @@ -15,6 +15,7 @@ CaptureAreaEditor::CaptureAreaEditor(const CommonModels &models, : QWidget(parent) , doTranslation_(new QCheckBox(tr("Translate:"), this)) , isLocked_(new QCheckBox(tr("Save (can capture via hotkey)"), this)) + , useHunspell_(new QCheckBox(tr("Use auto corrections"), this)) , sourceLanguage_(new QComboBox(this)) , targetLanguage_(new QComboBox(this)) { @@ -31,6 +32,9 @@ CaptureAreaEditor::CaptureAreaEditor(const CommonModels &models, layout->addWidget(doTranslation_, row, 0); layout->addWidget(targetLanguage_, row, 1); + ++row; + layout->addWidget(useHunspell_, row, 0, 1, 2); + ++row; layout->addWidget(isLocked_, row, 0, 1, 2); @@ -64,6 +68,7 @@ void CaptureAreaEditor::swapLanguages() void CaptureAreaEditor::set(const CaptureArea &area) { isLocked_->setChecked(area.isLocked()); + useHunspell_->setChecked(area.useHunspell_); doTranslation_->setChecked(area.doTranslation_); sourceLanguage_->setCurrentText(LanguageCodes::name(area.sourceLanguage_)); targetLanguage_->setCurrentText(LanguageCodes::name(area.targetLanguage_)); @@ -72,6 +77,7 @@ void CaptureAreaEditor::set(const CaptureArea &area) void CaptureAreaEditor::apply(CaptureArea &area) const { area.isLocked_ = isLocked_->isChecked(); + area.useHunspell_ = useHunspell_->isChecked(); area.doTranslation_ = doTranslation_->isChecked(); area.sourceLanguage_ = LanguageCodes::idForName(sourceLanguage_->currentText()); diff --git a/src/capture/captureareaeditor.h b/src/capture/captureareaeditor.h index 0159c8c..9127ac6 100644 --- a/src/capture/captureareaeditor.h +++ b/src/capture/captureareaeditor.h @@ -23,6 +23,7 @@ private: QCheckBox* doTranslation_; QCheckBox* isLocked_; + QCheckBox* useHunspell_; QComboBox* sourceLanguage_; QComboBox* targetLanguage_; }; diff --git a/src/correct/corrector.cpp b/src/correct/corrector.cpp index a8e853c..393071d 100644 --- a/src/correct/corrector.cpp +++ b/src/correct/corrector.cpp @@ -1,13 +1,37 @@ #include "corrector.h" +#include "correctorworker.h" #include "debug.h" #include "manager.h" #include "settings.h" #include "task.h" +#include + Corrector::Corrector(Manager &manager, const Settings &settings) : manager_(manager) , settings_(settings) + , workerThread_(new QThread(this)) { + auto worker = new CorrectorWorker; + connect(this, &Corrector::resetAuto, // + worker, &CorrectorWorker::reset); + connect(this, &Corrector::correctAuto, // + worker, &CorrectorWorker::handle); + connect(worker, &CorrectorWorker::finished, // + this, &Corrector::finishCorrection); + connect(workerThread_, &QThread::finished, // + worker, &QObject::deleteLater); + + workerThread_->start(); + worker->moveToThread(workerThread_); +} + +Corrector::~Corrector() +{ + workerThread_->quit(); + const auto timeoutMs = 2000; + if (!workerThread_->wait(timeoutMs)) + workerThread_->terminate(); } void Corrector::correct(const TaskPtr &task) @@ -20,11 +44,26 @@ void Corrector::correct(const TaskPtr &task) return; } + task->corrected = task->recognized; + if (!settings_.userSubstitutions.empty()) task->corrected = substituteUser(task->recognized, task->sourceLanguage); - else - task->corrected = task->recognized; + if (task->useHunspell) { + emit correctAuto(task); + return; + } + + finishCorrection(task); +} + +void Corrector::updateSettings() +{ + emit resetAuto(settings_.hunspellDir); +} + +void Corrector::finishCorrection(const TaskPtr &task) +{ manager_.corrected(task); } diff --git a/src/correct/corrector.h b/src/correct/corrector.h index 9f0c029..3b61e30 100644 --- a/src/correct/corrector.h +++ b/src/correct/corrector.h @@ -2,17 +2,28 @@ #include "stfwd.h" -class Corrector +#include + +class Corrector : public QObject { + Q_OBJECT public: Corrector(Manager &manager, const Settings &settings); + ~Corrector(); void correct(const TaskPtr &task); + void updateSettings(); + +signals: + void correctAuto(const TaskPtr &task); + void resetAuto(const QString &tessdataPath); private: + void finishCorrection(const TaskPtr &task); QString substituteUser(const QString &source, const LanguageId &language) const; Manager &manager_; const Settings &settings_; + QThread *workerThread_; }; diff --git a/src/correct/correctorworker.cpp b/src/correct/correctorworker.cpp new file mode 100644 index 0000000..c76d74b --- /dev/null +++ b/src/correct/correctorworker.cpp @@ -0,0 +1,66 @@ +#include "correctorworker.h" +#include "debug.h" +#include "hunspellcorrector.h" +#include "task.h" + +CorrectorWorker::CorrectorWorker() = default; + +CorrectorWorker::~CorrectorWorker() = default; + +void CorrectorWorker::handle(const TaskPtr &task) +{ + SOFT_ASSERT(task, return ); + SOFT_ASSERT(task->isValid(), return ); + SOFT_ASSERT(!hunspellDir_.isEmpty(), return ); + + auto result = task; + + if (!bundles_.count(task->sourceLanguage)) { + auto engine = + std::make_unique(task->sourceLanguage, hunspellDir_); + + if (!engine->isValid()) { + LWARNING() + << tr("Failed to init hunspell engine: %1").arg(engine->error()); + emit finished(result); + return; + } + + bundles_.emplace(task->sourceLanguage, Bundle{std::move(engine), 0}); + } + + auto &bundle = bundles_[task->sourceLanguage]; + SOFT_ASSERT(bundle.hunspell->isValid(), return ); + + result->corrected = bundle.hunspell->correct(task->corrected); + + const auto keepGenerations = 10; + bundle.usesLeft = keepGenerations; + removeUnused(task->generation); + lastGeneration_ = task->generation; + + emit finished(result); +} + +void CorrectorWorker::reset(const QString &hunspellDir) +{ + if (hunspellDir_ == hunspellDir) + return; + + hunspellDir_ = hunspellDir; + bundles_.clear(); +} + +void CorrectorWorker::removeUnused(Generation current) +{ + if (lastGeneration_ == current) + return; + + for (auto it = bundles_.begin(), end = bundles_.end(); it != end;) { + if (it->second.usesLeft >= 0) { + ++it; + } else { + it = bundles_.erase(it); + } + } +} diff --git a/src/correct/correctorworker.h b/src/correct/correctorworker.h new file mode 100644 index 0000000..27a5871 --- /dev/null +++ b/src/correct/correctorworker.h @@ -0,0 +1,32 @@ +#pragma once + +#include "stfwd.h" + +#include + +class HunspellCorrector; + +class CorrectorWorker : public QObject +{ + Q_OBJECT +public: + CorrectorWorker(); + ~CorrectorWorker(); + + void handle(const TaskPtr &task); + void reset(const QString &hunspellDir); + +signals: + void finished(const TaskPtr &task); + +private: + struct Bundle { + std::unique_ptr hunspell; + int usesLeft; + }; + void removeUnused(Generation current); + + std::map bundles_; + Generation lastGeneration_{}; + QString hunspellDir_; +}; diff --git a/src/correct/hunspellcorrector.cpp b/src/correct/hunspellcorrector.cpp new file mode 100644 index 0000000..9626b5a --- /dev/null +++ b/src/correct/hunspellcorrector.cpp @@ -0,0 +1,169 @@ +#include "hunspellcorrector.h" +#include "debug.h" +#include "languagecodes.h" +#include "settings.h" + +#include + +#include +#include +#include + +static int levenshteinDistance(const QString &source, const QString &target) +{ + if (source == target) + return 0; + + const auto sourceCount = source.size(); + const auto targetCount = target.size(); + + if (sourceCount == 0) + return targetCount; + + if (targetCount == 0) + return sourceCount; + + if (sourceCount > targetCount) + return levenshteinDistance(target, source); + + QVector previousColumn; + previousColumn.reserve(targetCount + 1); + for (auto i = 0; i < targetCount + 1; ++i) previousColumn.append(i); + + QVector column(targetCount + 1, 0); + for (auto i = 0; i < sourceCount; ++i) { + column[0] = i + 1; + for (auto j = 0; j < targetCount; ++j) { + column[j + 1] = std::min( + {1 + column.at(j), 1 + previousColumn.at(1 + j), + previousColumn.at(j) + ((source.at(i) == target.at(j)) ? 0 : 1)}); + } + column.swap(previousColumn); + } + + return previousColumn.at(targetCount); +} + +HunspellCorrector::HunspellCorrector(const LanguageId &language, + const QString &dictPath) +{ + const auto name = LanguageCodes::iso639_1(language); + init(dictPath + QLatin1Char('/') + name); +} + +HunspellCorrector::~HunspellCorrector() = default; + +const QString &HunspellCorrector::error() const +{ + return error_; +} + +bool HunspellCorrector::isValid() const +{ + return engine_.get(); +} + +void HunspellCorrector::init(const QString &path) +{ + SOFT_ASSERT(!engine_, return ); + + QDir dir(path); + if (!dir.exists()) { + error_ = QObject::tr("Hunspell path not exists\n%1").arg(path); + return; + } + + QString aff; + QStringList dics; + for (const auto &file : dir.entryList(QDir::Filter::Files)) { + if (file.endsWith(".aff")) { + aff = dir.absoluteFilePath(file); + continue; + } + if (file.endsWith(".dic")) { + dics.append(dir.absoluteFilePath(file)); + continue; + } + } + + if (aff.isEmpty() || dics.isEmpty()) { + error_ = QObject::tr("No .aff or .dic files for hunspell\nin %1").arg(path); + return; + } + + engine_ = + std::make_unique(qPrintable(aff), qPrintable(dics.first())); + + dics.pop_front(); + if (!dics.isEmpty()) { + for (const auto &dic : dics) engine_->add_dic(qPrintable(dic)); + } +} + +QString HunspellCorrector::correct(const QString &original) +{ + SOFT_ASSERT(engine_, return original); + + const auto codec = + QTextCodec::codecForName(engine_->get_dict_encoding().c_str()); + SOFT_ASSERT(codec, return original); + + QString result; + + QString word; + QString separator; + for (auto i = 0, end = original.size(); i < end; ++i) { + const auto ch = original[i]; + if (ch.isPunct() || ch.isSpace()) { + if (!word.isEmpty()) { + correctWord(word, *codec); + result += word; + word.clear(); + } + separator += ch; + continue; + } + + if (!ch.isLetter() && word.isEmpty()) { + separator += ch; + continue; + } + + if (!separator.isEmpty()) { + result += separator; + separator.clear(); + } + word += ch; + } + + if (!word.isEmpty()) { + correctWord(word, *codec); + result += word; + } + result += separator; + + return result; +} + +void HunspellCorrector::correctWord(QString &word, QTextCodec &codec) const +{ + if (word.isEmpty()) + return; + + const auto stdWord = codec.fromUnicode(word).toStdString(); + if (engine_->spell(stdWord)) + return; + + const auto suggestions = engine_->suggest(stdWord); + if (suggestions.empty()) + return; + + const auto suggestion = QByteArray::fromStdString(suggestions.front()); + const auto distance = levenshteinDistance(word, suggestion); + const auto maxDistance = std::max(int(word.size() * 0.2), 1); + LTRACE() << "hunspell" << word << suggestion << "distances" << distance + << maxDistance; + + if (distance <= maxDistance) + word = suggestion; +} diff --git a/src/correct/hunspellcorrector.h b/src/correct/hunspellcorrector.h new file mode 100644 index 0000000..58c728c --- /dev/null +++ b/src/correct/hunspellcorrector.h @@ -0,0 +1,26 @@ +#pragma once + +#include "stfwd.h" + +#include + +class Hunspell; + +class HunspellCorrector +{ +public: + explicit HunspellCorrector(const LanguageId& language, + const QString& dictPath); + ~HunspellCorrector(); + + const QString& error() const; + bool isValid() const; + QString correct(const QString& original); + +private: + void init(const QString& path); + void correctWord(QString& word, QTextCodec& codec) const; + + std::unique_ptr engine_; + QString error_; +}; diff --git a/src/manager.cpp b/src/manager.cpp index d0e7f17..4fcd0cb 100644 --- a/src/manager.cpp +++ b/src/manager.cpp @@ -41,6 +41,7 @@ Manager::Manager() // updater components (void)QT_TRANSLATE_NOOP("QObject", "app"); (void)QT_TRANSLATE_NOOP("QObject", "recognizers"); + (void)QT_TRANSLATE_NOOP("QObject", "hunspell"); (void)QT_TRANSLATE_NOOP("QObject", "translators"); tray_ = std::make_unique(*this, *settings_); @@ -111,6 +112,7 @@ void Manager::updateSettings() tray_->updateSettings(); capturer_->updateSettings(); recognizer_->updateSettings(); + corrector_->updateSettings(); translator_->updateSettings(); representer_->updateSettings(); @@ -147,6 +149,7 @@ void Manager::setupUpdates(const Settings &settings) updater_->model()->setExpansions({ {"$translators$", settings.translatorsDir}, {"$tessdata$", settings.tessdataPath}, + {"$hunspell$", settings.hunspellDir}, }); SOFT_ASSERT(updateAutoChecker_, return ); diff --git a/src/settings.cpp b/src/settings.cpp index e5fd0e8..1669e53 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -34,6 +34,7 @@ const QString qs_ocrLanguage = "language"; const QString qs_correctionGroup = "Correction"; const QString qs_userSubstitutions = "userSubstitutions"; const QString qs_useUserSubstitutions = "useUserSubstitutions"; +const QString qs_useHunspell = "useHunspell"; const QString qs_translationGroup = "Translation"; const QString qs_doTranslation = "doTranslation"; @@ -173,6 +174,7 @@ void Settings::save() const settings.endGroup(); settings.beginGroup(qs_correctionGroup); + settings.setValue(qs_useHunspell, useHunspell); settings.setValue(qs_useUserSubstitutions, useUserSubstitutions); settings.setValue(qs_userSubstitutions, packSubstitutions(userSubstitutions)); settings.endGroup(); @@ -258,6 +260,7 @@ void Settings::load() settings.endGroup(); settings.beginGroup(qs_correctionGroup); + useHunspell = settings.value(qs_useHunspell, useHunspell).toBool(); useUserSubstitutions = settings.value(qs_useUserSubstitutions, useUserSubstitutions).toBool(); userSubstitutions = @@ -328,4 +331,5 @@ void Settings::setPortable(bool isPortable) : QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); tessdataPath = baseDataPath + "/tessdata"; translatorsDir = baseDataPath + "/translators"; + hunspellDir = baseDataPath + "/hunspell"; } diff --git a/src/settings.h b/src/settings.h index df4ef91..4a65906 100644 --- a/src/settings.h +++ b/src/settings.h @@ -48,6 +48,8 @@ public: int autoUpdateIntervalDays{0}; QDateTime lastUpdateCheck; + bool useHunspell{false}; + QString hunspellDir; Substitutions userSubstitutions; bool useUserSubstitutions{true}; diff --git a/src/settingseditor.cpp b/src/settingseditor.cpp index ce52f6f..e4d5246 100644 --- a/src/settingseditor.cpp +++ b/src/settingseditor.cpp @@ -145,6 +145,7 @@ Settings SettingsEditor::settings() const settings.sourceLanguage = LanguageCodes::idForName(ui->tesseractLangCombo->currentText()); + settings.useHunspell = ui->useHunspell->isChecked(); settings.useUserSubstitutions = ui->useUserSubstitutions->isChecked(); settings.userSubstitutions = ui->userSubstitutionsTable->substitutions(); @@ -207,6 +208,8 @@ void SettingsEditor::setSettings(const Settings &settings) ui->tesseractLangCombo->setCurrentText( LanguageCodes::name(settings.sourceLanguage)); + ui->useHunspell->setChecked(settings.useHunspell); + ui->hunspellDir->setText(settings.hunspellDir); ui->useUserSubstitutions->setChecked(settings.useUserSubstitutions); ui->userSubstitutionsTable->setSubstitutions(settings.userSubstitutions); @@ -308,6 +311,7 @@ void SettingsEditor::handlePortableChanged() settings.setPortable(ui->portable->isChecked()); ui->tessdataPath->setText(settings.tessdataPath); ui->translatorsPath->setText(settings.translatorsDir); + ui->hunspellDir->setText(settings.hunspellDir); updateModels(settings.tessdataPath); updateTranslators(); diff --git a/src/settingseditor.ui b/src/settingseditor.ui index ac34488..f1f1679 100644 --- a/src/settingseditor.ui +++ b/src/settingseditor.ui @@ -286,24 +286,17 @@ - + - Substitutions + User substitutions Qt::AlignCenter - - - - Substitute recognized text - - - - + QAbstractItemView::SelectRows @@ -313,6 +306,34 @@ + + + + Use auto corrections (hunspell) + + + + + + + Use user substitutions + + + + + + + Hunspell dictionaries path: + + + + + + + + + + diff --git a/src/task.h b/src/task.h index 341201b..aeafeaf 100644 --- a/src/task.h +++ b/src/task.h @@ -19,6 +19,8 @@ public: QString corrected; QString translated; + bool useHunspell{false}; + LanguageId sourceLanguage; LanguageId targetLanguage; diff --git a/updates.json b/updates.json index 9c5db6f..c00f167 100644 --- a/updates.json +++ b/updates.json @@ -382,6 +382,196 @@ ]} } + +,"hunspell": { + "Thai":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/th_TH/th_TH.aff", "path":"$hunspell$/th/th_TH.aff", "date":"2019-04-30T09:35:45+02:00", "size":156} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/th_TH/th_TH.dic", "path":"$hunspell$/th/th_TH.dic", "date":"2019-06-04T14:18:16+02:00", "size":1251425} + ]} + , "Gujarati":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/gu_IN/gu_IN.aff", "path":"$hunspell$/gu/gu_IN.aff", "date":"2012-10-16T11:09:27-05:00", "size":174} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/gu_IN/gu_IN.dic", "path":"$hunspell$/gu/gu_IN.dic", "date":"2012-10-16T11:09:27-05:00", "size":3792870} + ]} + , "Afrikaans":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/af_ZA/af_ZA.aff", "path":"$hunspell$/af/af_ZA.aff", "date":"2020-02-16T20:22:16+01:00", "size":5027} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/af_ZA/af_ZA.dic", "path":"$hunspell$/af/af_ZA.dic", "date":"2020-02-16T20:22:16+01:00", "size":1262203} + ]} + , "Norwegian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/no/nb_NO.aff", "path":"$hunspell$/no/nb_NO.aff", "date":"2013-05-23T11:54:36+01:00", "size":17259} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/no/nb_NO.dic", "path":"$hunspell$/no/nb_NO.dic", "date":"2018-09-05T10:30:32+02:00", "size":5274030} + ]} + , "Albanian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sq_AL/sq_AL.aff", "path":"$hunspell$/sq/sq_AL.aff", "date":"2017-10-23T23:57:24+02:00", "size":7555} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sq_AL/sq_AL.dic", "path":"$hunspell$/sq/sq_AL.dic", "date":"2017-10-23T23:57:24+02:00", "size":2605147} + ]} + , "Italian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/it_IT/it_IT.aff", "path":"$hunspell$/it/it_IT.aff", "date":"2012-10-16T11:09:27-05:00", "size":80216} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/it_IT/it_IT.dic", "path":"$hunspell$/it/it_IT.dic", "date":"2012-10-16T11:09:27-05:00", "size":1290681} + ]} + , "Nepali":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/ne_NP/ne_NP.aff", "path":"$hunspell$/ne/ne_NP.aff", "date":"2012-10-16T11:09:27-05:00", "size":14162} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/ne_NP/ne_NP.dic", "path":"$hunspell$/ne/ne_NP.dic", "date":"2012-10-16T11:09:27-05:00", "size":874372} + ]} + , "Danish":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/da_DK/da_DK.aff", "path":"$hunspell$/da/da_DK.aff", "date":"2019-07-14T00:13:36+02:00", "size":55779} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/da_DK/da_DK.dic", "path":"$hunspell$/da/da_DK.dic", "date":"2019-07-14T00:13:36+02:00", "size":2915525} + ]} + , "Ukrainian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/uk_UA/uk_UA.aff", "path":"$hunspell$/uk/uk_UA.aff", "date":"2012-10-16T11:09:27-05:00", "size":159599} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/uk_UA/uk_UA.dic", "path":"$hunspell$/uk/uk_UA.dic", "date":"2012-10-16T11:09:27-05:00", "size":2584267} + ]} + , "Gaelic":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/gd_GB/gd_GB.aff", "path":"$hunspell$/gd/gd_GB.aff", "date":"2017-06-22T00:27:25+02:00", "size":8228} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/gd_GB/gd_GB.dic", "path":"$hunspell$/gd/gd_GB.dic", "date":"2017-06-22T00:27:25+02:00", "size":4806682} + ]} + , "Estonian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/et_EE/et_EE.aff", "path":"$hunspell$/et/et_EE.aff", "date":"2012-10-16T11:09:27-05:00", "size":236336} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/et_EE/et_EE.dic", "path":"$hunspell$/et/et_EE.dic", "date":"2012-10-16T11:09:27-05:00", "size":4383841} + ]} + , "Greek":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/el_GR/el_GR.aff", "path":"$hunspell$/el/el_GR.aff", "date":"2015-09-21T17:56:43+02:00", "size":15647} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/el_GR/el_GR.dic", "path":"$hunspell$/el/el_GR.dic", "date":"2015-09-21T17:56:43+02:00", "size":10125390} + ]} + , "Bulgarian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/bg_BG/bg_BG.aff", "path":"$hunspell$/bg/bg_BG.aff", "date":"2018-06-29T12:25:29+02:00", "size":58189} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/bg_BG/bg_BG.dic", "path":"$hunspell$/bg/bg_BG.dic", "date":"2018-06-29T12:25:29+02:00", "size":1566331} + ]} + , "Croatian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/hr_HR/hr_HR.aff", "path":"$hunspell$/hr/hr_HR.aff", "date":"2018-05-29T22:11:06+02:00", "size":95802} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/hr_HR/hr_HR.dic", "path":"$hunspell$/hr/hr_HR.dic", "date":"2018-05-29T22:11:06+02:00", "size":731819} + ]} + , "Polish":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/pl_PL/pl_PL.aff", "path":"$hunspell$/pl/pl_PL.aff", "date":"2017-05-05T15:26:38+02:00", "size":246842} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/pl_PL/pl_PL.dic", "path":"$hunspell$/pl/pl_PL.dic", "date":"2017-05-21T10:58:59+02:00", "size":4539105} + ]} + , "Galician":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/gl/gl_ES.aff", "path":"$hunspell$/gl/gl_ES.aff", "date":"2018-12-28T04:42:44+01:00", "size":1159910} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/gl/gl_ES.dic", "path":"$hunspell$/gl/gl_ES.dic", "date":"2018-12-28T04:42:44+01:00", "size":8636406} + ]} + , "Bengali":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/bn_BD/bn_BD.aff", "path":"$hunspell$/bn/bn_BD.aff", "date":"2012-10-16T11:09:27-05:00", "size":195} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/bn_BD/bn_BD.dic", "path":"$hunspell$/bn/bn_BD.dic", "date":"2012-10-16T11:09:27-05:00", "size":2596038} + ]} + , "Dutch":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/nl_NL/nl_NL.aff", "path":"$hunspell$/nl/nl_NL.aff", "date":"2013-07-22T17:41:01+00:00", "size":27835} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/nl_NL/nl_NL.dic", "path":"$hunspell$/nl/nl_NL.dic", "date":"2013-07-22T17:41:01+00:00", "size":1881063} + ]} + , "Bosnian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/bs_BA/bs_BA.aff", "path":"$hunspell$/bs/bs_BA.aff", "date":"2013-01-22T17:32:09+01:00", "size":17468} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/bs_BA/bs_BA.dic", "path":"$hunspell$/bs/bs_BA.dic", "date":"2013-01-22T17:32:09+01:00", "size":339863} + ]} + , "Swahili":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sw_TZ/sw_TZ.aff", "path":"$hunspell$/sw/sw_TZ.aff", "date":"2012-10-16T11:09:27-05:00", "size":974} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sw_TZ/sw_TZ.dic", "path":"$hunspell$/sw/sw_TZ.dic", "date":"2012-10-16T11:09:27-05:00", "size":630844} + ]} + , "Slovenian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sl_SI/sl_SI.aff", "path":"$hunspell$/sl/sl_SI.aff", "date":"2012-10-16T11:09:27-05:00", "size":14730} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sl_SI/sl_SI.dic", "path":"$hunspell$/sl/sl_SI.dic", "date":"2012-10-16T11:09:27-05:00", "size":2967766} + ]} + , "French":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/fr_FR/fr.aff", "path":"$hunspell$/fr/fr.aff", "date":"2018-08-24T15:00:59+02:00", "size":256857} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/fr_FR/fr.dic", "path":"$hunspell$/fr/fr.dic", "date":"2018-08-24T15:00:59+02:00", "size":1100397} + ]} + , "Latvian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/lv_LV/lv_LV.aff", "path":"$hunspell$/lv/lv_LV.aff", "date":"2012-10-16T11:09:27-05:00", "size":69597} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/lv_LV/lv_LV.dic", "path":"$hunspell$/lv/lv_LV.dic", "date":"2012-10-16T11:09:27-05:00", "size":2226834} + ]} + , "German":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/de/de_DE_frami.aff", "path":"$hunspell$/de/de_DE_frami.aff", "date":"2017-01-22T19:03:05+00:00", "size":18991} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/de/de_DE_frami.dic", "path":"$hunspell$/de/de_DE_frami.dic", "date":"2017-01-22T19:03:05+00:00", "size":4356858} + ]} + , "Tibetan":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/bo/bo.aff", "path":"$hunspell$/bo/bo.aff", "date":"2016-11-22T22:23:34+00:00", "size":1706} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/bo/bo.dic", "path":"$hunspell$/bo/bo.dic", "date":"2017-10-23T18:37:13+02:00", "size":4637} + ]} + , "Telugu":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/te_IN/te_IN.aff", "path":"$hunspell$/te/te_IN.aff", "date":"2012-10-16T11:09:27-05:00", "size":160} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/te_IN/te_IN.dic", "path":"$hunspell$/te/te_IN.dic", "date":"2012-10-16T11:09:27-05:00", "size":3402272} + ]} + , "Czech":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/cs_CZ/cs_CZ.aff", "path":"$hunspell$/cs/cs_CZ.aff", "date":"2015-04-07T12:40:39+02:00", "size":97286} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/cs_CZ/cs_CZ.dic", "path":"$hunspell$/cs/cs_CZ.dic", "date":"2019-09-16T06:45:26+02:00", "size":2209232} + ]} + , "Slovak":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sk_SK/sk_SK.aff", "path":"$hunspell$/sk/sk_SK.aff", "date":"2013-05-09T10:20:05+02:00", "size":99414} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sk_SK/sk_SK.dic", "path":"$hunspell$/sk/sk_SK.dic", "date":"2013-05-09T10:20:05+02:00", "size":3289769} + ]} + , "Hebrew":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/he_IL/he_IL.aff", "path":"$hunspell$/he/he_IL.aff", "date":"2017-09-05T18:11:31+02:00", "size":78883} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/he_IL/he_IL.dic", "path":"$hunspell$/he/he_IL.dic", "date":"2017-09-05T18:11:31+02:00", "size":7796259} + ]} + , "Russian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/ru_RU/ru_RU.aff", "path":"$hunspell$/ru/ru_RU.aff", "date":"2012-10-16T11:09:27-05:00", "size":53019} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/ru_RU/ru_RU.dic", "path":"$hunspell$/ru/ru_RU.dic", "date":"2012-10-16T11:09:27-05:00", "size":1969349} + ]} + , "Arabic":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/ar/ar.aff", "path":"$hunspell$/ar/ar.aff", "date":"2018-02-04T21:34:12+01:00", "size":86949} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/ar/ar.dic", "path":"$hunspell$/ar/ar.dic", "date":"2019-03-07T11:32:58+01:00", "size":7217161} + ]} + , "Vietnamese":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/vi/vi_VN.aff", "path":"$hunspell$/vi/vi_VN.aff", "date":"2012-10-16T11:09:27-05:00", "size":788} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/vi/vi_VN.dic", "path":"$hunspell$/vi/vi_VN.dic", "date":"2012-10-16T11:09:27-05:00", "size":39852} + ]} + , "Portuguese":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/pt_PT/pt_PT.aff", "path":"$hunspell$/pt/pt_PT.aff", "date":"2016-07-07T10:15:29+02:00", "size":95089} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/pt_PT/pt_PT.dic", "path":"$hunspell$/pt/pt_PT.dic", "date":"2016-10-03T18:05:09+00:00", "size":1473077} + ]} + , "Turkish":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/tr_TR/tr_TR.aff", "path":"$hunspell$/tr/tr_TR.aff", "date":"2018-08-27T16:55:14+02:00", "size":235315} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/tr_TR/tr_TR.dic", "path":"$hunspell$/tr/tr_TR.dic", "date":"2018-08-27T16:55:14+02:00", "size":9061155} + ]} + , "Indonesian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/id/id_ID.aff", "path":"$hunspell$/id/id_ID.aff", "date":"2018-02-28T01:40:08+01:00", "size":14957} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/id/id_ID.dic", "path":"$hunspell$/id/id_ID.dic", "date":"2018-02-28T01:40:08+01:00", "size":315384} + ]} + , "Hindi":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/hi_IN/hi_IN.aff", "path":"$hunspell$/hi/hi_IN.aff", "date":"2012-10-16T11:09:27-05:00", "size":210} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/hi_IN/hi_IN.dic", "path":"$hunspell$/hi/hi_IN.dic", "date":"2012-10-16T11:09:27-05:00", "size":303963} + ]} + , "Lithuanian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/lt_LT/lt.aff", "path":"$hunspell$/lt/lt.aff", "date":"2013-01-23T11:35:37+00:00", "size":92208} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/lt_LT/lt.dic", "path":"$hunspell$/lt/lt.dic", "date":"2013-01-23T11:35:37+00:00", "size":1085291} + ]} + , "Spanish":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/es/es_ANY.aff", "path":"$hunspell$/es/es_ANY.aff", "date":"2019-10-22T21:26:40+02:00", "size":169377} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/es/es_ANY.dic", "path":"$hunspell$/es/es_ANY.dic", "date":"2019-10-22T21:26:40+02:00", "size":804058} + ]} + , "Icelandic":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/is/is.aff", "path":"$hunspell$/is/is.aff", "date":"2016-03-14T09:05:09+00:00", "size":309734} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/is/is.dic", "path":"$hunspell$/is/is.dic", "date":"2016-03-14T09:05:09+00:00", "size":2454138} + ]} + , "Lao":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/lo_LA/lo_LA.aff", "path":"$hunspell$/lo/lo_LA.aff", "date":"2013-11-24T19:21:08+01:00", "size":10} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/lo_LA/lo_LA.dic", "path":"$hunspell$/lo/lo_LA.dic", "date":"2013-11-24T19:21:08+01:00", "size":203209} + ]} + , "Romanian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/ro/ro_RO.aff", "path":"$hunspell$/ro/ro_RO.aff", "date":"2013-03-28T11:26:45+01:00", "size":55181} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/ro/ro_RO.dic", "path":"$hunspell$/ro/ro_RO.dic", "date":"2013-03-28T11:26:45+01:00", "size":2196348} + ]} + , "Serbian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sr/sr.aff", "path":"$hunspell$/sr/sr.aff", "date":"2019-04-20T11:24:57+02:00", "size":901060} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sr/sr.dic", "path":"$hunspell$/sr/sr.dic", "date":"2019-04-20T11:24:57+02:00", "size":5878745} + ]} + , "Belarusian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/be_BY/be_BY.aff", "path":"$hunspell$/be/be_BY.aff", "date":"2012-10-16T11:09:27-05:00", "size":23968} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/be_BY/be_BY.dic", "path":"$hunspell$/be/be_BY.dic", "date":"2012-10-16T11:09:27-05:00", "size":1707840} + ]} + , "English":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/en/en_US.aff", "path":"$hunspell$/en/en_US.aff", "date":"2018-05-15T00:49:14+02:00", "size":3090} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/en/en_US.dic", "path":"$hunspell$/en/en_US.dic", "date":"2019-11-13T00:44:49+01:00", "size":551260} + ]} + , "Hungarian":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/hu_HU/hu_HU.aff", "path":"$hunspell$/hu/hu_HU.aff", "date":"2018-05-22T22:26:58+02:00", "size":2106214} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/hu_HU/hu_HU.dic", "path":"$hunspell$/hu/hu_HU.dic", "date":"2018-05-22T22:26:58+02:00", "size":1653155} + ]} + , "Swedish":{"files":[ + {"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sv_SE/sv_FI.aff", "path":"$hunspell$/sv/sv_FI.aff", "date":"2015-09-08T21:02:20+00:00", "size":18583} + ,{"url":"https://cgit.freedesktop.org/libreoffice/dictionaries/plain/sv_SE/sv_FI.dic", "path":"$hunspell$/sv/sv_FI.dic", "date":"2016-08-16T20:00:33+00:00", "size":2317112} + ]} +} + + + ,"translators":{ "bing": {"files":[ {"url":"https://raw.githubusercontent.com/OneMoreGres/ScreenTranslator/master/translators/bing.js", "path":"$translators$/bing.js", "md5":"fc69c1e05a3462a88131ee0a8422ad89"}