Release v0.6.8: add background image OCR and optional Tesseract

This commit is contained in:
2026-10-29 21:40:29 +02:00
parent 95c7199027
commit 107d2f8852
21 changed files with 590 additions and 37 deletions
+76 -3
View File
@@ -44,6 +44,13 @@ bool validId(const std::wstring &id)
} // namespace
void validateSettings(const Settings &s)
{
if (s.tesseractPath.size() > 32700 || s.tesseractPath.find_first_of(L"\r\n\"") != s.tesseractPath.npos)
throw std::runtime_error("Invalid Tesseract executable path");
if (s.tesseractLanguages.empty() || s.tesseractLanguages.size() > 128 ||
s.tesseractLanguages.front() == L'+' || s.tesseractLanguages.back() == L'+' ||
s.tesseractLanguages.find(L"++") != s.tesseractLanguages.npos ||
s.tesseractLanguages.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+") != s.tesseractLanguages.npos)
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
std::vector<Hotkey> used;
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
@@ -125,6 +132,9 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.switchLayout = j.value("switchSystemLayout", true);
s.writeLog = j.value("writeLog", true);
s.history = j.value("historyEnabled", true);
s.useTesseract = j.value("useTesseract", false);
s.tesseractPath = str(j, "tesseractPath");
s.tesseractLanguages = str(j, "tesseractLanguages", s.tesseractLanguages);
s.autostart = j.value("startWithSystem", false);
s.elevatedAutostart = j.value("autostartAsAdministrator", false);
s.awake = j.value("preventScreenLock", false);
@@ -196,6 +206,9 @@ Bytes encodeSettings(const Settings &s)
{"writeLog", s.writeLog},
{"autostartAsAdministrator", s.elevatedAutostart},
{"historyEnabled", s.history},
{"useTesseract", s.useTesseract},
{"tesseractPath", utf8(s.tesseractPath)},
{"tesseractLanguages", utf8(s.tesseractLanguages)},
{"startWithSystem", s.autostart},
{"preventScreenLock", s.awake},
{"maxItems", s.maxItems},
@@ -297,7 +310,8 @@ History::History(const std::filesystem::path &folder) : folder_(folder)
e.value("at", uint64_t{}),
e.value("pinned", false),
e.value("image", false),
e.value("files", false)};
e.value("files", false), e.value("ocrState", e.value("image", false) ? 1 : 0),
str(e, "ocrProfile", L"windows")};
if (validId(item.id) && std::filesystem::exists(folder_ / (item.id + L".dat")))
entries_.push_back(std::move(item));
}
@@ -313,7 +327,7 @@ void History::saveIndex()
{"at", e.at},
{"pinned", e.pinned},
{"image", e.image},
{"files", e.files}});
{"files", e.files}, {"ocrState", e.ocrState}, {"ocrProfile", utf8(e.ocrProfile)}});
atomicWrite(folder_ / L"index.dat", protect(jsonBytes(j)));
}
std::vector<Entry> History::entries() const
@@ -342,6 +356,7 @@ void History::prune(const Settings &s)
total -= entries_[i].bytes;
std::error_code ec;
std::filesystem::remove(folder_ / (entries_[i].id + L".dat"), ec);
std::filesystem::remove(folder_ / (entries_[i].id + L".ocr"), ec);
entries_.erase(entries_.begin() + i);
}
}
@@ -429,7 +444,8 @@ void History::add(const Clip &clip, const std::wstring &app, const Settings &s)
GetSystemTimeAsFileTime(&now);
entries_.insert(entries_.begin(),
Entry{id, text, app, encrypted.size(),
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files});
(uint64_t(now.dwHighDateTime) << 32) | now.dwLowDateTime, false, image, files,
image ? 1 : 0, ocrProfile(s)});
prune(s);
saveIndex();
}
@@ -445,6 +461,7 @@ void History::remove(const std::wstring &id)
saveIndex();
std::error_code ec;
std::filesystem::remove(folder_ / (id + L".dat"), ec);
std::filesystem::remove(folder_ / (id + L".ocr"), ec);
}
void History::pin(const std::wstring &id)
{
@@ -464,8 +481,49 @@ void History::clear()
{
std::error_code ec;
std::filesystem::remove(folder_ / (e.id + L".dat"), ec);
std::filesystem::remove(folder_ / (e.id + L".ocr"), ec);
}
}
bool History::saveOcr(const std::wstring &id, const std::optional<std::wstring> &text, const Settings &settings)
{
std::lock_guard lock(mutex_);
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
// A late OCR completion must never recreate a deleted/pruned image.
auto profile = ocrProfile(settings);
if (it == entries_.end() || !it->image || (it->ocrState == 2 && it->ocrProfile == profile)) return false;
if (text)
{
auto utf = utf8(*text);
if (utf.size() > 1024 * 1024) throw std::runtime_error("OCR text exceeds storage limit");
// A version byte also makes empty recognition results encryptable by DPAPI.
Bytes payload{1};
payload.insert(payload.end(), utf.begin(), utf.end());
auto encrypted = protect(payload);
auto path = folder_ / (id + L".ocr");
std::error_code ec;
auto previousBytes = std::filesystem::file_size(path, ec);
if (ec) previousBytes = 0;
atomicWrite(path, encrypted);
it->bytes = it->bytes - std::min(it->bytes, uint64_t(previousBytes)) + encrypted.size();
it->ocrState = 2;
}
else it->ocrState = 3;
it->ocrProfile = profile;
prune(settings);
saveIndex();
return true;
}
std::optional<std::wstring> History::ocrText(const std::wstring &id, const std::wstring &profile) const
{
{
std::lock_guard lock(mutex_);
auto it = std::find_if(entries_.begin(), entries_.end(), [&](auto &e) { return e.id == id; });
if (it == entries_.end() || it->ocrState != 2 || (!profile.empty() && it->ocrProfile != profile)) return std::nullopt;
}
auto bytes = unprotect(readFile(folder_ / (id + L".ocr"), 2 * 1024 * 1024));
if (bytes.empty() || bytes.front() != 1) throw std::runtime_error("Invalid OCR text format");
return wide(std::string(bytes.begin() + 1, bytes.end()));
}
std::vector<Entry> History::search(const std::wstring &query) const
{
auto items = entries();
@@ -477,6 +535,21 @@ std::vector<Entry> History::search(const std::wstring &query) const
{
if (lower(e.preview + L" " + e.app).find(q) != q.npos)
return false;
if (e.ocrState == 2)
{
try
{
auto text = ocrText(e.id);
if (text && lower(*text).find(q) != q.npos) return false;
}
catch (...) {} // Missing/corrupt OCR must not hide the original clip.
}
// Short previews contain the complete text (or a placeholder for
// textless clips). Do not decrypt image payloads just to rule them out.
// Newlines/tabs are flattened in previews, so those queries still
// need the original text. Exactly 240 characters may be truncated.
if (e.preview.size() < 240 && q.find_first_of(L"\r\n\t") == q.npos)
return true;
try
{
return lower(load(e.id).text()).find(q) == q.npos;