412 lines
17 KiB
C++
412 lines
17 KiB
C++
#include "autocorrect.h"
|
||
#include <algorithm>
|
||
#include <charconv>
|
||
#include <uiautomation.h>
|
||
namespace ks
|
||
{
|
||
static bool openingDelimiter(wchar_t c, const Settings &settings)
|
||
{
|
||
return settings.autoPrefixChars.find(c) != std::wstring::npos;
|
||
}
|
||
static bool trailingDelimiter(wchar_t c, const Settings &settings)
|
||
{
|
||
return c == L' ' || settings.autoSuffixChars.find(c) != std::wstring::npos;
|
||
}
|
||
bool autoClosingSelection(const std::wstring &text, const Settings &settings)
|
||
{
|
||
return !text.empty() && text.size() <= 6 &&
|
||
std::all_of(text.begin(), text.end(), [&](wchar_t c) { return trailingDelimiter(c, settings); }) &&
|
||
text.find_first_not_of(L' ') != text.npos;
|
||
}
|
||
std::wstring layoutLanguage(const Layout &layout)
|
||
{
|
||
if (layout.id == L"en" || layout.id == L"uk" || layout.id == L"ru")
|
||
return layout.id;
|
||
switch (PRIMARYLANGID(LOWORD((uintptr_t)layout.handle)))
|
||
{
|
||
case LANG_ENGLISH:
|
||
return L"en";
|
||
case LANG_UKRAINIAN:
|
||
return L"uk";
|
||
case LANG_RUSSIAN:
|
||
return L"ru";
|
||
default:
|
||
return {};
|
||
}
|
||
}
|
||
bool safeAutoWord(const std::wstring &word, int minimumLength)
|
||
{
|
||
if (minimumLength < 1 || minimumLength > 32 || word.size() < (size_t)minimumLength || word.size() > 32 ||
|
||
!std::all_of(word.begin(), word.end(), letter))
|
||
return false;
|
||
auto small = lower(word);
|
||
return word == small || (word.substr(1) == small.substr(1) && word != upper(word));
|
||
}
|
||
namespace {
|
||
constexpr size_t DictionaryBlockWords = 32;
|
||
}
|
||
std::string_view AutoCorrector::Dictionary::word(size_t block) const
|
||
{
|
||
size_t start = offsets[block];
|
||
// Every block starts with a complete word (zero shared prefix).
|
||
size_t length = static_cast<unsigned char>(bytes[start++]) & 15;
|
||
if (length == 15) length = static_cast<unsigned char>(bytes[start++]);
|
||
return std::string_view(bytes).substr(start, length);
|
||
}
|
||
AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path)
|
||
{
|
||
const auto raw = readFile(path, 2 * 1024 * 1024);
|
||
const std::string_view source(reinterpret_cast<const char *>(raw.data()), raw.size());
|
||
std::vector<uint32_t> lines;
|
||
lines.reserve(std::count(source.begin(), source.end(), '\n') + 1);
|
||
for (size_t start = 0; start < source.size();)
|
||
{
|
||
auto end = source.find('\n', start);
|
||
if (end == source.npos) end = source.size();
|
||
auto tab = source.find('\t', start);
|
||
if (tab == source.npos || tab >= end || tab == start || tab - start > 160)
|
||
throw std::runtime_error("Invalid auto-correction dictionary");
|
||
int rank{};
|
||
auto rankEnd = end > tab + 1 && source[end - 1] == '\r' ? end - 1 : end;
|
||
auto parse = std::from_chars(source.data() + tab + 1, source.data() + rankEnd, rank);
|
||
if (parse.ec != std::errc() || parse.ptr != source.data() + rankEnd || rank <= 0)
|
||
throw std::runtime_error("Invalid dictionary rank");
|
||
lines.push_back(static_cast<uint32_t>(start));
|
||
start = end + 1;
|
||
}
|
||
if (lines.empty()) throw std::runtime_error("Empty auto-correction dictionary");
|
||
auto entry = [&](uint32_t offset) { return source.substr(offset, source.find('\t', offset) - offset); };
|
||
auto frequency = [&](uint32_t offset) {
|
||
int rank{};
|
||
auto start = offset + entry(offset).size() + 1;
|
||
std::from_chars(source.data() + start, source.data() + source.size(), rank);
|
||
return rank;
|
||
};
|
||
std::sort(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) {
|
||
return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b);
|
||
});
|
||
lines.erase(std::unique(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) {
|
||
return entry(a) == entry(b);
|
||
}), lines.end());
|
||
offsets.reserve((lines.size() + DictionaryBlockWords - 1) / DictionaryBlockWords);
|
||
std::string_view previous;
|
||
// Allocate the packed payload exactly once. Geometric growth and a final
|
||
// shrink otherwise leave discarded buffers in the resident process heap.
|
||
size_t packedSize = 0;
|
||
for (size_t i = 0; i < lines.size(); ++i)
|
||
{
|
||
auto current = entry(lines[i]);
|
||
size_t prefix = 0;
|
||
if (i % DictionaryBlockWords != 0)
|
||
while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix;
|
||
auto suffix = current.size() - prefix;
|
||
packedSize += 1 + (prefix >= 15) + (suffix >= 15) + suffix + 2;
|
||
auto rank = static_cast<uint32_t>(frequency(lines[i]));
|
||
if (rank >= 65535)
|
||
do { ++packedSize; rank >>= 7; } while (rank);
|
||
previous = current;
|
||
}
|
||
bytes.reserve(packedSize);
|
||
previous = {};
|
||
for (size_t i = 0; i < lines.size(); ++i)
|
||
{
|
||
auto current = entry(lines[i]);
|
||
size_t prefix = 0;
|
||
if (i % DictionaryBlockWords == 0)
|
||
offsets.push_back(static_cast<uint32_t>(bytes.size()));
|
||
else
|
||
while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix;
|
||
size_t suffix = current.size() - prefix;
|
||
bytes.push_back(static_cast<char>((std::min(prefix, size_t(15)) << 4) | std::min(suffix, size_t(15))));
|
||
if (prefix >= 15) bytes.push_back(static_cast<char>(prefix));
|
||
if (suffix >= 15) bytes.push_back(static_cast<char>(suffix));
|
||
bytes.append(current.substr(prefix));
|
||
auto rank = static_cast<uint32_t>(frequency(lines[i]));
|
||
auto shortRank = std::min(rank, uint32_t(65535));
|
||
bytes.push_back(static_cast<char>(shortRank & 255));
|
||
bytes.push_back(static_cast<char>(shortRank >> 8));
|
||
if (rank >= 65535)
|
||
do {
|
||
auto next = rank & 127;
|
||
rank >>= 7;
|
||
bytes.push_back(static_cast<char>(next | (rank ? 128 : 0)));
|
||
} while (rank);
|
||
previous = current;
|
||
}
|
||
}
|
||
int AutoCorrector::Dictionary::rank(const std::wstring &text) const
|
||
{
|
||
auto key = utf8(lower(text));
|
||
size_t begin = 0, end = offsets.size();
|
||
// Find the last block whose first word is <= the search key.
|
||
while (begin < end)
|
||
{
|
||
auto mid = begin + (end - begin) / 2;
|
||
if (word(mid) <= key) begin = mid + 1;
|
||
else end = mid;
|
||
}
|
||
if (!begin) return 0;
|
||
size_t block = begin - 1;
|
||
size_t at = offsets[block];
|
||
const size_t limit = begin < offsets.size() ? offsets[begin] : bytes.size();
|
||
char decoded[160]; // The loader validates lengths; lookup never allocates a decoded word.
|
||
while (at < limit)
|
||
{
|
||
auto lengths = static_cast<unsigned char>(bytes[at++]);
|
||
size_t prefix = lengths >> 4, suffix = lengths & 15;
|
||
if (prefix == 15) prefix = static_cast<unsigned char>(bytes[at++]);
|
||
if (suffix == 15) suffix = static_cast<unsigned char>(bytes[at++]);
|
||
memcpy(decoded + prefix, bytes.data() + at, suffix);
|
||
at += suffix;
|
||
uint32_t rank = static_cast<unsigned char>(bytes[at]) |
|
||
(uint32_t(static_cast<unsigned char>(bytes[at + 1])) << 8);
|
||
at += 2;
|
||
if (rank == 65535) {
|
||
rank = 0;
|
||
uint32_t shift = 0;
|
||
unsigned char part;
|
||
do {
|
||
part = static_cast<unsigned char>(bytes[at++]);
|
||
rank |= uint32_t(part & 127) << shift;
|
||
shift += 7;
|
||
} while (part & 128);
|
||
}
|
||
auto compared = std::string_view(decoded, size_t(prefix) + suffix).compare(key);
|
||
if (compared == 0) return static_cast<int>(rank);
|
||
if (compared > 0) return 0;
|
||
}
|
||
return 0;
|
||
}
|
||
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
|
||
{
|
||
if (languages == wanted)
|
||
return;
|
||
std::map<std::wstring, std::shared_ptr<const Dictionary>> next;
|
||
for (auto &lang : wanted)
|
||
{
|
||
if (lang != L"en" && lang != L"uk" && lang != L"ru")
|
||
throw std::runtime_error("Unsupported dictionary language");
|
||
next.emplace(lang, std::make_shared<const Dictionary>(folder / (lang + L".txt")));
|
||
}
|
||
dictionaries = std::move(next);
|
||
languages = wanted;
|
||
}
|
||
void AutoCorrector::shareDictionaries(const AutoCorrector &source)
|
||
{
|
||
dictionaries = source.dictionaries;
|
||
languages = source.languages;
|
||
}
|
||
void AutoCorrector::reject(std::wstring word, const Settings &settings)
|
||
{
|
||
while (!word.empty() && openingDelimiter(word.front(), settings))
|
||
word.erase(word.begin());
|
||
while (!word.empty() && trailingDelimiter(word.back(), settings))
|
||
word.pop_back();
|
||
if (rejected.size() >= 128)
|
||
rejected.clear();
|
||
rejected.insert(lower(word));
|
||
}
|
||
size_t AutoCorrector::memoryBytes() const
|
||
{
|
||
size_t bytes = 0;
|
||
for (auto &[language, d] : dictionaries)
|
||
bytes += d->bytes.capacity() + d->offsets.capacity() * sizeof(uint32_t);
|
||
return bytes;
|
||
}
|
||
std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
|
||
const std::vector<Layout> &layouts,
|
||
const Settings &settings) const
|
||
{
|
||
if (source < 0 || source >= (int)layouts.size() || !layouts[source].convertible)
|
||
return {};
|
||
std::wstring word = typed, suffix, prefix;
|
||
while (!word.empty() && openingDelimiter(word.front(), settings) && prefix.size() < 3)
|
||
{
|
||
prefix += word.front();
|
||
word.erase(word.begin());
|
||
}
|
||
while (!word.empty() && trailingDelimiter(word.back(), settings))
|
||
{
|
||
suffix.insert(suffix.begin(), word.back());
|
||
word.pop_back();
|
||
}
|
||
// Punctuation keys can be letters in the target layout (for example ',' -> 'б').
|
||
// Validate letters and capitalization after conversion, not before it.
|
||
if (settings.autoMinLength < 1 || settings.autoMinLength > 32 ||
|
||
word.size() < (size_t)settings.autoMinLength || word.size() > 32 ||
|
||
suffix.size() > 6 || rejected.contains(lower(word)))
|
||
return {};
|
||
for (auto &never : settings.autoNever)
|
||
if (lower(never) == lower(word))
|
||
return {};
|
||
auto sourceLanguage = layoutLanguage(layouts[source]);
|
||
if (!dictionaries.contains(sourceLanguage))
|
||
return {};
|
||
const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" :
|
||
sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя";
|
||
auto small = lower(word);
|
||
bool sameLanguage = safeAutoWord(word, settings.autoMinLength) &&
|
||
small.find_first_not_of(alphabet) == small.npos &&
|
||
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end();
|
||
auto sameLayout = [&](std::wstring text)
|
||
{
|
||
if (word != small) text[0] = upper(text.substr(0, 1))[0];
|
||
return AutoSuggestion{source, source, prefix + text + suffix};
|
||
};
|
||
if (settings.autoReplace && sameLanguage)
|
||
{
|
||
auto replacement = settings.autoReplacements.find(small);
|
||
if (replacement != settings.autoReplacements.end() &&
|
||
replacement->second.find_first_not_of(alphabet) == small.npos)
|
||
return sameLayout(replacement->second);
|
||
}
|
||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||
{
|
||
auto found = settings.autoWords.find(language);
|
||
return found != settings.autoWords.end() &&
|
||
std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known)
|
||
{ return lower(known) == lower(candidate); });
|
||
};
|
||
// Valid words from either selected language are never changed.
|
||
for (auto &[language, d] : dictionaries)
|
||
if (d->rank(word) || personal(language, word))
|
||
return {};
|
||
std::optional<AutoSuggestion> candidate;
|
||
std::optional<std::wstring> matchedText;
|
||
auto priority = [&](int layout)
|
||
{
|
||
auto language = layoutLanguage(layouts[layout]);
|
||
return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin();
|
||
};
|
||
for (int i = 0; i < (int)layouts.size(); ++i)
|
||
{
|
||
if (i == source || !layouts[i].convertible ||
|
||
std::find(settings.cycle.begin(), settings.cycle.end(), layouts[i].id) == settings.cycle.end())
|
||
continue;
|
||
auto dictionary = dictionaries.find(layoutLanguage(layouts[i]));
|
||
if (dictionary == dictionaries.end())
|
||
continue;
|
||
auto converted = convert(word, layouts[source], layouts[i], true);
|
||
if (converted == word || !safeAutoWord(converted, settings.autoMinLength))
|
||
continue;
|
||
int rank = personal(dictionary->first, converted) ? 1 : dictionary->second->rank(converted);
|
||
if (!rank)
|
||
continue;
|
||
if (matchedText && *matchedText != converted)
|
||
return {};
|
||
matchedText = converted;
|
||
if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) &&
|
||
(!candidate || priority(i) < priority(candidate->target)))
|
||
candidate = AutoSuggestion{source, i, prefix + converted + suffix};
|
||
}
|
||
if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4)
|
||
return candidate;
|
||
// Generate only single edits. Stop at a second distinct dictionary hit,
|
||
// including rare hits: frequency limits must never hide ambiguity.
|
||
std::wstring found;
|
||
int foundRank = 0;
|
||
std::set<std::wstring> personalTargets;
|
||
if (auto it = settings.autoWords.find(sourceLanguage); it != settings.autoWords.end())
|
||
for (const auto &text : it->second) personalTargets.insert(lower(text));
|
||
auto consider = [&](const std::wstring &text)
|
||
{
|
||
if (text == small || text == found || text.size() < 4 || text.size() > 32) return true;
|
||
int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage)->rank(text);
|
||
if (!rank) return true;
|
||
if (!found.empty()) return false;
|
||
found = text;
|
||
foundRank = rank;
|
||
return true;
|
||
};
|
||
for (size_t i = 0; i < small.size(); ++i)
|
||
{
|
||
auto text = small;
|
||
text.erase(i, 1);
|
||
if (!consider(text)) return {};
|
||
if (i + 1 < small.size())
|
||
{
|
||
text = small;
|
||
std::swap(text[i], text[i + 1]);
|
||
if (!consider(text)) return {};
|
||
}
|
||
for (wchar_t c : alphabet)
|
||
{
|
||
text = small;
|
||
text[i] = c;
|
||
if (!consider(text)) return {};
|
||
}
|
||
}
|
||
if (small.size() < 32)
|
||
for (size_t i = 0; i <= small.size(); ++i)
|
||
for (wchar_t c : alphabet)
|
||
{
|
||
auto text = small;
|
||
text.insert(i, 1, c);
|
||
if (!consider(text)) return {};
|
||
}
|
||
if (!found.empty() && (settings.autoMaxRank == 0 || foundRank <= settings.autoMaxRank))
|
||
return sameLayout(found);
|
||
return {};
|
||
}
|
||
int matchAutoInput(const Run &run, const std::wstring &text, const std::vector<Layout> &layouts,
|
||
const Layout &physical, const Settings &settings)
|
||
{
|
||
int full = run.match(text, layouts, physical);
|
||
if (full >= 0 || run.overflow || text.empty())
|
||
return full;
|
||
// Ctrl+Shift+Left may omit opening delimiters. Only these delimiters may
|
||
// be omitted; never accept an arbitrary matching suffix of a longer word.
|
||
for (int i = 0; i < (int)layouts.size(); ++i)
|
||
{
|
||
if (!layouts[i].convertible)
|
||
continue;
|
||
std::wstring expected;
|
||
for (wchar_t c : run.keys)
|
||
expected += c == L' ' ? L' ' : layouts[i].at(physical.code(c));
|
||
for (int removed = 0;
|
||
removed < 3 && !expected.empty() && openingDelimiter(expected.front(), settings); ++removed)
|
||
{
|
||
expected.erase(expected.begin());
|
||
if (lower(expected) == lower(text))
|
||
return i;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
bool autoCorrectionAllowed(HWND target, const Settings &settings)
|
||
{
|
||
if (!target || GetForegroundWindow() != target)
|
||
return false;
|
||
auto process = lower(processName(target));
|
||
for (auto &excluded : settings.autoExcluded)
|
||
{
|
||
auto name = lower(excluded);
|
||
if (name.ends_with(L".exe"))
|
||
name.resize(name.size() - 4);
|
||
if (name == process)
|
||
return false;
|
||
}
|
||
// Fail closed: do not copy from password fields or inaccessible controls.
|
||
IUIAutomation2 *automation{};
|
||
if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2,
|
||
(void **)&automation)))
|
||
return false;
|
||
automation->put_ConnectionTimeout(200);
|
||
automation->put_TransactionTimeout(200);
|
||
IUIAutomationElement *focused{};
|
||
HRESULT hr = automation->GetFocusedElement(&focused);
|
||
automation->Release();
|
||
if (FAILED(hr) || !focused)
|
||
return false;
|
||
BOOL password = TRUE, enabled = FALSE, hasFocus = FALSE;
|
||
CONTROLTYPEID type{};
|
||
bool allowed = SUCCEEDED(focused->get_CurrentIsPassword(&password)) && !password &&
|
||
SUCCEEDED(focused->get_CurrentIsEnabled(&enabled)) && enabled &&
|
||
SUCCEEDED(focused->get_CurrentHasKeyboardFocus(&hasFocus)) && hasFocus &&
|
||
SUCCEEDED(focused->get_CurrentControlType(&type)) &&
|
||
(type == UIA_EditControlTypeId || type == UIA_DocumentControlTypeId);
|
||
focused->Release();
|
||
return allowed && GetForegroundWindow() == target;
|
||
}
|
||
} // namespace ks
|