Compare commits

..

3 Commits

21 changed files with 819 additions and 82 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.1 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.8 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
@@ -36,7 +36,7 @@ if(MSVC)
else()
target_link_options(KSwitcherScreenshotUITests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_executable(KSwitcherOcrTests tests/ocr.cpp)
add_executable(KSwitcherOcrTests tests/ocr.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/app.cpp src/screenshot.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_link_libraries(KSwitcherOcrTests PRIVATE kscore)
if(MSVC)
set_property(TARGET kscore KSwitcher KSwitcherConfig KSwitcherScreenshot KSwitcherTests KSwitcherIntegration KSwitcherInputTests KSwitcherOcrTests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
+30 -5
View File
@@ -90,8 +90,14 @@ broker can load them even when the profile directory is inaccessible to it.
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
The editor starts with **Pencil / Freehand (F)** for drawing. Choose **Pointer (V)** to select objects, move them, and edit geometry, thickness and
other properties using handles and controls. Clicking empty space clears selection; dragging empty
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
The cursor identifies the active tool: pointer, text caret, pencil, or a crosshair with the drawing
tool symbol. Move/resize/thickness/rotation handles override it with their action cursor. Custom
cursors scale with display DPI and use a contrasting outline for light and dark images.
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
red arrows by dragging from the tail to the tip. With **Pointer (V)**, click an existing marker or arrow to select it;
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
@@ -102,8 +108,9 @@ and update linked arrows. **Ctrl+D** clones the selected object with a small off
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
and other arrows or strokes. Drop an endpoint onto the target; use **Ctrl+drag** to start a new
connector over an existing object. Attached endpoints follow movement and resizing; text attachments
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; text attachments
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
@@ -136,6 +143,8 @@ The mouse wheel over text adjusts its font size. Text supports color, dragging,
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
automatic angle; only dragging the rotation handle switches to a fixed angle.
New standalone text starts horizontally, even after editing an angled arrow caption. Font styling
is reused; rotation belongs to each individual object.
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
@@ -214,8 +223,24 @@ thumbnails and source labels aligned with the scaled fonts and spacing.
stylesheet rules are not reproduced; preview text uses the list's colors for readability.
- Consecutive screenshot copies are compared by image content, ignoring changing OLE transport
metadata (including Snipping Tool's repeated clipboard publication).
- Windows OCR, entirely native and offline. Right-click an image in history and select
**Copy text from image**. Requires a Windows OCR language installed for the user profile.
- New images are recognized automatically in a separate background worker using native, offline
Windows OCR. Recognized text is saved in a per-user DPAPI-encrypted sidecar and included in search,
without changing the image or clipboard. Completed results (including empty text) are reused;
pending work resumes after restart. Older images without OCR are processed once in the background,
starting with the newest entries.
Right-click an image and select **Copy text from image** to copy the saved text or explicitly
retry recognition. Requires a Windows OCR language installed for the user profile; automatic
failures are logged quietly. OCR sidecars count toward the history size limit and are removed
with their images.
- Optional Tesseract 5 support is available in **Settings → OCR options…**. Windows OCR remains
the default and has no Tesseract dependency. Install the 64-bit Tesseract 5 Windows package
separately, then enable it and choose language codes such as `ukr+eng+rus`. Leave the executable
path blank to detect a standard installation, or select `tesseract.exe`; its neighboring
`libtesseract-5.dll`, dependencies and `tessdata` language files are required. **Check installation**
validates the selected models. **Download Tesseract…** opens the Windows installer page in your
browser. No packages or models are downloaded automatically.
Tesseract processes screenshots locally in memory with a 30-second recognition deadline.
Changing engine, executable path or languages refreshes cached image text in the background.
- History exclusion flags and excluded processes, pause/resume, count/size limits.
- Per-user Windows DPAPI encryption for both history payloads and the preview index.
- Autostart, keep-screen-awake, idle-only F15 activity signal, settings import/export.
Binary file not shown.
+15 -19
View File
@@ -1,34 +1,30 @@
# Latest Windows release
## v0.6.5 — 2026-10-29
## v0.6.8 — 2026-10-29
## Changes
- Preserve endpoint links when selecting a connector or adding/editing its caption through double-click, F2 or the caption button. A click without movement no longer detaches the connector.
- Attach arrow and line endpoints to rectangles, circles, ellipses, text blocks, other arrows/lines, and freehand/highlight strokes, in addition to numbered markers.
- Keep attachments aligned as targets move or resize; text attachments also follow rotation. Arrow-to-arrow chains resolve in dependency order.
- Preserve links through Undo/Redo. Deleted targets leave endpoints at their last position; clones start without links. Self-links and circular dependencies are rejected.
## Usage
Drop a connector endpoint onto the target. Use **Ctrl+drag** to start a new connector over an existing object. Moving a whole connector detaches both of its endpoints; dragging one endpoint detaches only that end.
- Recognize clipboard images automatically in a background worker, including older images without OCR. Save recognized text encrypted alongside the original image and search it without rerunning OCR.
- Keep clipboard capture, previews and OCR from delaying history search. Skip unnecessary image-payload reads; ignore superseded search results.
- Add optional Tesseract 5 support in Settings → OCR options, with automatic installation detection, executable selection, combined languages such as ukr+eng+rus, installation validation and a Download Tesseract button linking to the Windows installer page. Windows OCR remains the default; no downloads are automatic.
- Refresh cached image text when the OCR engine or languages change. Reuse OCR for Copy text from image; preserve the clipboard during automatic recognition.
- Start the screenshot editor with Pencil / Freehand (F). Pointer remains available with V.
## Validation
176 native checks, 440 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. Coverage includes caption editing, shape-arrow-arrow chains, movement, resize, text rotation, deletion, cloning, cycle rejection, and Undo/Redo.
203 native checks, 445 input/layout checks, screenshot editor UI integration (including the initial pencil cursor), isolated notification registration, and 45 updater checks. Native Windows OCR and optional Tesseract tests verify background recognition, encrypted persistence, unchanged clipboard, mixed Ukrainian/English text, and engine-cache migration. OCR settings were also checked in the running configurator.
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the
v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder.
Keep personal `data/` and customized dictionaries.
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
For manual installation, exit KSwitcher and extract the entire ZIP into its installation folder. Keep personal `data/` and customized dictionaries.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains the resident,
configurator and screenshot executables, a release manifest, offline dictionaries, documentation
and license notices; no personal settings or clipboard history.
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains all three executables, a release manifest, offline dictionaries, documentation and license notices. It contains no personal settings, clipboard history, Tesseract installation or language models.
Documentation: [English](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Clipboard) · [Українська](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Clipboard-uk).
Build and package:
```powershell
.\build.ps1 -Stage -Test
.\build.ps1 -Test -Ocr
.\build\KSwitcherOcrTests.exe --tesseract
.\package.ps1 -PublishRelease
```
Commit the archive, checksum and sources together. Publish both assets on a stable
`release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
Commit the archive, checksum and sources together. Publish both assets on a stable `release-vX.Y.Z` tag matching the executable version. Keep `v0.4.2` as the last legacy tag.
+1 -1
View File
@@ -1 +1 @@
bea45fdb33657fdfd5b065cdf10a3c358febdd5e895672dd106ec5ef2dd16c9a KSwitcher-win-x64.zip
31c2c9923ff0f6d71eb421bcea890409cedf7a48999ebc513d0adedf5a2c9839 KSwitcher-win-x64.zip
+1 -1
View File
@@ -69,7 +69,7 @@ try {
if ($LASTEXITCODE) { throw 'Integration tests failed' }
}
if ($Ocr) {
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherOcrTests.exe
& $compiler @common tests/ocr.cpp build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR test build failed' }
& .\build\KSwitcherOcrTests.exe
if ($LASTEXITCODE) { throw 'OCR tests failed' }
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,5,0
PRODUCTVERSION 0,6,5,0
FILEVERSION 0,6,8,0
PRODUCTVERSION 0,6,8,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.5"
VALUE "FileVersion", "0.6.8"
END
END
BLOCK "VarFileInfo"
+92 -1
View File
@@ -269,8 +269,15 @@ App::~App()
std::lock_guard lock(jobsMutex);
stop = true;
jobs.clear();
pendingHistorySearch = {};
}
jobsReady.notify_all();
historySearchReady.notify_all();
historyOcrReady.notify_all();
if (historyOcrWorker.joinable())
historyOcrWorker.join();
if (historySearchWorker.joinable())
historySearchWorker.join();
if (worker.joinable())
worker.join();
RemoveClipboardFormatListener(window);
@@ -291,6 +298,83 @@ void App::enqueue(std::function<void()> job)
}
jobsReady.notify_one();
}
void App::enqueueHistorySearch(std::function<void()> job)
{
{
std::lock_guard lock(jobsMutex);
if (stop) return;
// Only the latest query matters. Clipboard capture and OCR must not delay it.
pendingHistorySearch = std::move(job);
if (!historySearchWorker.joinable())
historySearchWorker = std::thread([this]
{
for (;;)
{
std::function<void()> searchJob;
{
std::unique_lock lock(jobsMutex);
historySearchReady.wait(lock, [&] { return stop || bool(pendingHistorySearch); });
if (stop) return;
searchJob = std::move(pendingHistorySearch);
pendingHistorySearch = {};
}
try { searchJob(); }
catch (const std::exception &e) { report(wide(e.what())); }
}
});
}
historySearchReady.notify_one();
}
void App::scheduleHistoryOcr(const Settings &config)
{
{
std::lock_guard lock(jobsMutex);
if (stop || !history || !config.history) return;
historyOcrSettings = config;
historyOcrRequested = true;
if (!historyOcrWorker.joinable())
historyOcrWorker = std::thread([this]
{
for (;;)
{
Settings config;
{
std::unique_lock lock(jobsMutex);
historyOcrReady.wait(lock, [&] { return stop || historyOcrRequested; });
if (stop) return;
historyOcrRequested = false;
config = historyOcrSettings;
}
for (const auto &entry : history->entries())
{
{ std::lock_guard lock(jobsMutex); if (stop) return; }
auto profile = ocrProfile(config);
if (!entry.image || (entry.ocrState != 1 && entry.ocrProfile == profile)) continue;
try
{
auto text = recognizeText(history->load(entry.id), config);
std::lock_guard lock(jobsMutex);
if (stop || ocrProfile(historyOcrSettings) != profile) continue;
if (history->saveOcr(entry.id, text, config))
PostMessageW(window, HistoryMessage, 0, 0);
}
catch (const std::exception &e)
{
// Automatic OCR never changes the clipboard or opens error dialogs.
log(L"History OCR: " + wide(e.what()));
try {
std::lock_guard lock(jobsMutex);
if (!stop && ocrProfile(historyOcrSettings) == profile)
history->saveOcr(entry.id, std::nullopt, config);
}
catch (const std::exception &saveError) { log(L"History OCR save: " + wide(saveError.what())); }
}
}
}
});
}
historyOcrReady.notify_one();
}
void App::report(const std::wstring &text)
{
log(text);
@@ -333,6 +417,7 @@ void App::applySettings(const Settings &s)
{
bool autostartChanged = false;
validateSettings(s);
validateTesseract(s);
if (s.autoCorrect)
{
AutoCorrector probe;
@@ -375,6 +460,7 @@ void App::applySettings(const Settings &s)
}
// Clear on the worker so the next check reloads edits to dictionary files.
enqueue([this] { autoCorrector.configure({}, {}); });
scheduleHistoryOcr(s);
SetThreadExecutionState(ES_CONTINUOUS |
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
}
@@ -1051,6 +1137,7 @@ void App::captureHistory()
!f.data[1] && !f.data[2] && !f.data[3])
return;
history->add(clip, app, config);
scheduleHistoryOcr(config);
PostMessageW(window, HistoryMessage, 0, 0);
}
catch (const std::exception &e)
@@ -1078,7 +1165,9 @@ void App::recognizeEntry(std::wstring id)
} reset{busy};
try
{
auto text = recognizeText(history->load(id));
auto cached = history->ocrText(id, ocrProfile(config));
auto text = cached ? *cached : recognizeText(history->load(id), config);
if (!cached) history->saveOcr(id, text, config);
if (text.empty())
{
report(L"No text was found in the image.");
@@ -1442,6 +1531,8 @@ int App::execute()
if (!keyboardHook || !mouseHook)
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
AddClipboardFormatListener(window);
// Resume pending OCR, including older images without a saved OCR state.
scheduleHistoryOcr(settings);
addTray();
SetTimer(window, 1, 60000, nullptr);
SetThreadExecutionState(ES_CONTINUOUS |
+9
View File
@@ -61,6 +61,7 @@ class App
void recognizeEntry(std::wstring);
void captureHistory();
void enqueue(std::function<void()>);
void enqueueHistorySearch(std::function<void()>);
void report(const std::wstring &);
void trayMenu();
void addTray();
@@ -83,6 +84,14 @@ class App
std::condition_variable jobsReady;
std::deque<std::function<void()>> jobs;
std::thread worker;
std::thread historySearchWorker;
std::condition_variable historySearchReady;
std::function<void()> pendingHistorySearch;
std::thread historyOcrWorker;
std::condition_variable historyOcrReady;
bool historyOcrRequested{};
Settings historyOcrSettings;
void scheduleHistoryOcr(const Settings &);
std::thread updateWorker;
bool updateRunning{}, installingUpdate{};
std::wstring availableUpdate, notifiedUpdate;
+3
View File
@@ -49,6 +49,9 @@ struct Settings
bool autoCorrect = false;
bool writeLog = true;
bool screenshotAutoOpenEditor = false;
bool useTesseract = false;
std::wstring tesseractPath;
std::wstring tesseractLanguages = L"ukr+eng+rus";
int autoMaxRank = 0;
int autoMinLength = 3;
std::wstring autoPrefixChars = L"([{«“‘\"";
+115
View File
@@ -271,4 +271,119 @@ std::wstring recognizeText(const Clip &clip)
WindowsDeleteString(text);
return out;
}
std::filesystem::path tesseractExecutable(const Settings &s)
{
if (!s.tesseractPath.empty())
{
std::filesystem::path path(s.tesseractPath);
if (!path.is_absolute()) throw std::runtime_error("Select an absolute path to tesseract.exe");
return path;
}
for (auto variable : {L"ProgramW6432", L"ProgramFiles", L"LOCALAPPDATA"})
{
wchar_t base[32768]{};
auto length = GetEnvironmentVariableW(variable, base, std::size(base));
if (!length || length >= std::size(base)) continue;
auto root = std::filesystem::path(base);
for (auto relative : {L"Tesseract-OCR/tesseract.exe", L"Programs/Tesseract-OCR/tesseract.exe"})
if (std::filesystem::is_regular_file(root / relative)) return root / relative;
}
throw std::runtime_error("Tesseract was not found. Install the optional 64-bit Tesseract 5 package or select tesseract.exe in OCR options.");
}
std::wstring ocrProfile(const Settings &s)
{
return s.useTesseract ? L"tesseract:" + lower(s.tesseractPath) + L":" + s.tesseractLanguages : L"windows";
}
namespace
{
struct TessLibrary
{
HMODULE module{};
explicit TessLibrary(const Settings &s)
{
auto exe = tesseractExecutable(s);
if (!std::filesystem::is_regular_file(exe)) throw std::runtime_error("Tesseract executable does not exist");
auto library = exe.parent_path() / L"libtesseract-5.dll";
module = LoadLibraryExW(library.c_str(), nullptr,
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
if (!module) throw std::runtime_error("Cannot load Tesseract 5. Select a complete 64-bit installation with libtesseract-5.dll and its dependencies.");
}
~TessLibrary() { if (module) FreeLibrary(module); }
template<class T> T symbol(const char *name)
{
auto address = GetProcAddress(module, name);
if (!address) throw std::runtime_error(std::string("Unsupported Tesseract library: missing ") + name);
T result; static_assert(sizeof(result) == sizeof(address));
memcpy(&result, &address, sizeof(result));
return result;
}
};
struct TessEngine
{
TessLibrary library;
void *handle{};
void (*destroy)(void *){};
explicit TessEngine(const Settings &s) : library(s)
{
auto folder = tesseractExecutable(s).parent_path() / L"tessdata";
size_t start = 0;
do
{
auto end = s.tesseractLanguages.find(L'+', start);
auto language = s.tesseractLanguages.substr(start, end == std::wstring::npos ? end : end - start);
if (!std::filesystem::is_regular_file(folder / (language + L".traineddata")))
throw std::runtime_error("Tesseract language is not installed: " + utf8(language));
if (end == std::wstring::npos) break;
start = end + 1;
} while (true);
auto create = library.symbol<void *(*)()>("TessBaseAPICreate");
destroy = library.symbol<void (*)(void *)>("TessBaseAPIDelete");
auto init = library.symbol<int (*)(void *, const char *, const char *)>("TessBaseAPIInit3");
handle = create();
if (!handle) throw std::runtime_error("Cannot create Tesseract engine");
if (init(handle, utf8(folder.wstring()).c_str(), utf8(s.tesseractLanguages).c_str()) != 0)
{
destroy(handle); handle = nullptr;
throw std::runtime_error("Cannot initialize the selected Tesseract languages");
}
}
~TessEngine() { if (handle) destroy(handle); }
};
}
void validateTesseract(const Settings &s)
{
if (s.useTesseract) { TessEngine engine(s); }
}
std::wstring recognizeText(const Clip &clip, const Settings &s)
{
if (!s.useTesseract) return recognizeText(clip);
auto apartmentResult = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (FAILED(apartmentResult) && apartmentResult != RPC_E_CHANGED_MODE) check(apartmentResult);
struct Apartment { bool owned; ~Apartment() { if (owned) CoUninitialize(); } } apartment{SUCCEEDED(apartmentResult)};
// Load the optional C API dynamically: Windows OCR users need no Tesseract DLLs.
TessEngine engine(s);
auto &lib = engine.library;
auto image = lib.symbol<void (*)(void *, const unsigned char *, int, int, int, int)>("TessBaseAPISetImage");
auto mode = lib.symbol<void (*)(void *, int)>("TessBaseAPISetPageSegMode");
auto recognize = lib.symbol<int (*)(void *, void *)>("TessBaseAPIRecognize");
auto getText = lib.symbol<char *(*)(void *)>("TessBaseAPIGetUTF8Text");
auto freeText = lib.symbol<void (*)(const char *)>("TessDeleteText");
auto createMonitor = lib.symbol<void *(*)()>("TessMonitorCreate");
auto freeMonitor = lib.symbol<void (*)(void *)>("TessMonitorDelete");
auto deadline = lib.symbol<void (*)(void *, int)>("TessMonitorSetDeadlineMSecs");
std::unique_ptr<void, void (*)(void *)> monitor(createMonitor(), freeMonitor);
if (!monitor) throw std::runtime_error("Cannot create Tesseract progress monitor");
auto pixels = decodeImage(clip);
Bytes gray(size_t(pixels.width) * pixels.height);
for (size_t i = 0; i < gray.size(); ++i)
gray[i] = BYTE((29 * pixels.bgra[4*i] + 150 * pixels.bgra[4*i+1] + 77 * pixels.bgra[4*i+2]) >> 8);
image(engine.handle, gray.data(), pixels.width, pixels.height, 1, pixels.width);
mode(engine.handle, 11); // Sparse text: screenshots often contain separate labels and paragraphs.
deadline(monitor.get(), 30000);
if (recognize(engine.handle, monitor.get()) != 0)
throw std::runtime_error("Tesseract recognition failed or exceeded 30 seconds");
std::unique_ptr<char, void (*)(const char *)> text(getText(engine.handle), freeText);
if (!text) throw std::runtime_error("Tesseract returned no result");
return wide(text.get());
}
} // namespace ks
+4
View File
@@ -10,4 +10,8 @@ struct Pixels
Pixels decodeImage(const Clip &, UINT maxWidth = 0, UINT maxHeight = 0);
Format imageDib(const Pixels &);
std::wstring recognizeText(const Clip &);
std::wstring recognizeText(const Clip &, const Settings &);
std::filesystem::path tesseractExecutable(const Settings &);
void validateTesseract(const Settings &);
std::wstring ocrProfile(const Settings &);
} // namespace ks
+107 -38
View File
@@ -53,7 +53,7 @@ bool confirmScreenshotClose(HWND owner)
// If Windows cannot display the confirmation, keep the screenshot open.
return SUCCEEDED(result) && button==IDYES;
}
constexpr int ToolIds[]{10,11,22,23,24,25,21,19,26,20,12,18,13};
constexpr int ToolIds[]{27,10,11,22,23,24,25,21,19,26,20,12,18,13};
struct Surface
{
HDC dc = CreateCompatibleDC(nullptr);
@@ -123,6 +123,44 @@ bool chooseTextFont(HWND owner,Mark &m)
m.fontWeight=font.lfWeight; m.fontItalic=font.lfItalic!=0;
return true;
}
constexpr int CursorTools[]{10,11,22,23,24,25,19};
HCURSOR makeToolCursor(int tool,int size)
{
// Monochrome AND/XOR cursor: white outline keeps the glyph visible on dark images.
auto bitmap=CreateBitmap(size,size*2,1,1,nullptr);
auto dc=CreateCompatibleDC(nullptr);
if(!bitmap || !dc) { if(bitmap) DeleteObject(bitmap); if(dc) DeleteDC(dc); return nullptr; }
auto oldBitmap=SelectObject(dc,bitmap);
PatBlt(dc,0,0,size,size,WHITENESS); PatBlt(dc,0,size,size,size,BLACKNESS);
auto oldBrush=SelectObject(dc,GetStockObject(NULL_BRUSH));
auto draw=[&](int offset,int width,COLORREF color) {
auto pen=CreatePen(PS_SOLID,width,color); auto oldPen=SelectObject(dc,pen);
auto x=[&](int v){return MulDiv(v,size,32);};
auto y=[&](int v){return offset+MulDiv(v,size,32);};
auto line=[&](int a,int b,int c,int d){MoveToEx(dc,x(a),y(b),nullptr); LineTo(dc,x(c),y(d));};
line(1,7,13,7); line(7,1,7,13); // The cross center is the exact drawing point.
switch(tool) {
case 10:
Ellipse(dc,x(17),y(17),x(30),y(30));
line(22,21,24,20); line(24,20,24,27); line(21,27,27,27); break;
case 11: line(18,29,29,18); line(22,18,29,18); line(29,18,29,25); break;
case 22: line(18,29,29,18); break;
case 23: Rectangle(dc,x(17),y(18),x(30),y(29)); break;
case 24: Ellipse(dc,x(17),y(17),x(30),y(30)); break;
case 25: Ellipse(dc,x(16),y(20),x(31),y(28)); break;
case 19:
line(18,25,25,18); line(25,18,29,22); line(29,22,22,29);
line(22,29,18,25); line(18,25,17,30); line(17,30,22,29); break;
}
SelectObject(dc,oldPen); DeleteObject(pen);
};
int outline=std::max(3,MulDiv(4,size,32)),stroke=std::max(1,MulDiv(2,size,32));
draw(0,outline,RGB(0,0,0));
draw(size,outline,RGB(255,255,255)); draw(size,stroke,RGB(0,0,0));
SelectObject(dc,oldBrush); SelectObject(dc,oldBitmap); DeleteDC(dc);
ICONINFO info{}; info.fIcon=FALSE; info.xHotspot=info.yHotspot=MulDiv(7,size,32); info.hbmMask=bitmap;
auto cursor=(HCURSOR)CreateIconIndirect(&info); DeleteObject(bitmap); return cursor;
}
struct Capture
{
ScreenshotHost &app;
@@ -131,7 +169,7 @@ struct Capture
bool editing{}, dragging{}, arrow{}, owned{}, copied{};
bool autoOpenEditor;
bool highlight{};
bool freehand=true;
bool pointer=false, freehand=true;
bool straight{}, textTool{};
Mark textDefaults{};
ScreenshotShape shape=ScreenshotShape::None;
@@ -159,6 +197,7 @@ struct Capture
HICON toolIcons[3][15]{};
int iconSize{};
HCURSOR pencilCursor{};
HCURSOR drawingCursors[std::size(CursorTools)]{};
int pencilCursorSize{};
int unit = 1, panelHeight = 1, statusHeight = 0;
int hoveredTool=0;
@@ -167,6 +206,7 @@ struct Capture
explicit Capture(ScreenshotHost &a) : app(a), autoOpenEditor(a.autoOpenEditor) {}
~Capture() {
if(pencilCursor) DestroyCursor(pencilCursor);
for(auto cursor:drawingCursors) if(cursor) DestroyCursor(cursor);
if (font) DeleteObject(font);
if(inlineFont) DeleteObject(inlineFont);
for(auto &variant:toolIcons) for(auto icon:variant) if(icon) DestroyIcon(icon);
@@ -207,11 +247,12 @@ void updateStatus(Capture &c)
else if(m.isText()) text=L"Text: arrows = 1 px | F2 = edit | Round handle = rotate; Shift = 15 degrees";
else text=L"Arrows = 1 px | Del = delete | Ctrl+D = clone | Ctrl+Z / Ctrl+Y = undo / redo";
}
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = draw over an object";
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object";
else if(c.arrow) text=L"Drag to draw | Ctrl+drag = draw over an object | F2 on a line = caption";
else text=L"Number (N): click to add | Drag objects to move | Ctrl+C = copy screenshot";
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
if(text!=c.statusText) {
c.statusText=std::move(text);
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
@@ -252,6 +293,15 @@ void positionInlineText(Capture &c)
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
}
Mark textStyleDefaults(const Mark &source)
{
// Reuse typography only, never an object's angle, position, identity or links.
Mark style{};
style.fontName=source.fontName; style.fontSize=source.fontSize;
style.fontWeight=source.fontWeight; style.fontItalic=source.fontItalic;
style.color=source.color;
return style;
}
void finishInlineText(HWND w,Capture &c,bool commit)
{
if(!c.inlineText) return;
@@ -274,7 +324,7 @@ void finishInlineText(HWND w,Capture &c,bool commit)
} else if(!empty) {
c.remember(c.marks); c.marks.push_back(c.textDraft); c.selected=int(c.marks.size())-1; c.selectedLabel=false;
}
if(!empty) c.textDefaults=c.textDraft;
if(!empty) c.textDefaults=textStyleDefaults(c.textDraft);
}
if(commit) resolveScreenshotLinks(c.marks);
c.textIndex=-1; c.editingLabel=false;
@@ -513,6 +563,11 @@ void layout(HWND w, Capture &c)
int dpi = GetDpiForWindow(w);
int cursorSize=GetSystemMetricsForDpi(SM_CXCURSOR,dpi);
if(cursorSize!=c.pencilCursorSize) {
for(size_t i=0;i<std::size(CursorTools);++i) {
auto cursor=makeToolCursor(CursorTools[i],cursorSize);
if(c.drawingCursors[i]) DestroyCursor(c.drawingCursors[i]);
c.drawingCursors[i]=cursor;
}
auto cursor=(HCURSOR)LoadImageW(c.app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,cursorSize,cursorSize,0);
if(cursor) {
if(c.pencilCursor) DestroyCursor(c.pencilCursor);
@@ -539,7 +594,7 @@ void layout(HWND w, Capture &c)
RECT statusRect{}; GetWindowRect(status,&statusRect);
c.statusHeight=statusRect.bottom-statusRect.top;
r.bottom-=c.statusHeight;
const int positions[]{8,44,80,116,152,188,224,260,296,332,380,416,464};
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,416,452,500};
static_assert(std::size(positions)==std::size(ToolIds));
for (size_t i = 0; i < std::size(ToolIds); ++i)
{
@@ -608,30 +663,41 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
}
return {-1,MarkPart::None};
}
HCURSOR toolCursor(const Capture &c)
{
if(c.pointer) return LoadCursorW(nullptr,IDC_ARROW);
if(c.textTool) return LoadCursorW(nullptr,IDC_IBEAM);
if(c.freehand && c.pencilCursor) return c.pencilCursor;
int tool=c.highlight?19:c.straight?22:c.shape!=ScreenshotShape::None?22+int(c.shape):c.arrow?11:10;
for(size_t i=0;i<std::size(CursorTools);++i)
if(CursorTools[i]==tool && c.drawingCursors[i]) return c.drawingCursors[i];
return LoadCursorW(nullptr,IDC_CROSS);
}
HCURSOR actionCursor(const Capture &c,std::pair<int,MarkPart> hit,bool inside)
{
if(!c.editing) return LoadCursorW(nullptr,IDC_CROSS);
LPCWSTR cursor=nullptr;
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness) {
const auto &mark=c.marks[hit.first];
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
cursor=cursors[axis];
}
return cursor?LoadCursorW(nullptr,cursor):inside?toolCursor(c):LoadCursorW(nullptr,IDC_ARROW);
}
void captureCursor(HWND w, Capture &c)
{
LPCWSTR cursor=IDC_CROSS;
if(c.editing)
{
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
auto hit=c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false));
if(!c.dragging && !PtInRect(&c.view,p) && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
if(!c.dragging && !PtInRect(&c.view,p)) cursor=IDC_ARROW;
if(hit.second==MarkPart::Body || hit.second==MarkPart::Label) cursor=IDC_SIZEALL;
else if(hit.second==MarkPart::Rotation) cursor=IDC_HAND;
else if(hit.second==MarkPart::Radius) cursor=IDC_SIZEWE;
else if(hit.second==MarkPart::Start || hit.second==MarkPart::End || hit.second==MarkPart::Thickness)
{
const auto &mark=c.marks[hit.first];
auto [from,to]=hit.second==MarkPart::Thickness?thicknessSegment(mark):std::pair{mark.from,mark.to};
double angle=std::atan2(double(to.y-from.y),double(to.x-from.x));
if(hit.second==MarkPart::Thickness) angle+=3.141592653589793/2;
int axis=(int(std::lround(angle/(3.141592653589793/4)))+8)%4;
const LPCWSTR cursors[]{IDC_SIZEWE,IDC_SIZENWSE,IDC_SIZENS,IDC_SIZENESW};
cursor=cursors[axis];
}
}
SetCursor(c.editing && c.freehand && cursor==IDC_CROSS && c.pencilCursor?c.pencilCursor:LoadCursorW(nullptr,cursor));
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
bool inside=PtInRect(&c.view,p)!=FALSE;
auto hit=c.editing?(c.dragging?std::pair{c.selected,c.part}:markAt(c,imagePoint(c,p,false))):std::pair{-1,MarkPart::None};
if(!c.dragging && !inside && hit.second!=MarkPart::Thickness && hit.second!=MarkPart::Rotation) hit={-1,MarkPart::None};
SetCursor(actionCursor(c,hit,inside || c.dragging));
if(c.editing) {
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
c.hoveredPart=markAt(c,imagePoint(c,p,false)).second;
@@ -698,7 +764,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
{
auto dc=item.hDC;
RECT r=item.rcItem;
bool active=(item.CtlID==10 && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
bool disabled=item.itemState&ODS_DISABLED;
@@ -714,7 +780,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
auto icon=c.toolIcons[disabled?2:active?1:0][iconIndex];
auto icon=item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
if(item.CtlID==20) {
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
@@ -775,7 +841,7 @@ void edit(HWND w, Capture &c)
InitCommonControlsEx(&statusControls);
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
const wchar_t *labels[]{L"Number (N)", L"Arrow (A)", L"Straight line (L)", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
static_assert(std::size(labels)==std::size(ToolIds));
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
@@ -867,7 +933,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
}
break;
case WM_DRAWITEM:
if((wp>=10 && wp<=13) || (wp>=18 && wp<=26) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
break;
case WM_CTLCOLORSTATIC:
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
@@ -894,7 +960,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
auto draft=c->marks[c->selected];
if(chooseTextFont(w,draft)) {
c->remember(c->marks); c->marks[c->selected]=draft;
c->textDefaults=draft.isLine()?screenshotArrowLabel(draft):draft;
c->textDefaults=textStyleDefaults(draft);
resolveScreenshotLinks(c->marks);
}
} else chooseTextFont(w,c->textDefaults);
@@ -958,8 +1024,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
c->syncHistory();
}
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=26))
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
{
c->pointer=LOWORD(wp)==27;
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
c->straight=LOWORD(wp)==22;
c->textTool=LOWORD(wp)==26;
@@ -973,10 +1040,10 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(GetDlgItem(w,11),nullptr,FALSE);
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
for(int id=22;id<=26;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
}
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
SetFocus(w); captureCursor(w,*c); InvalidateRect(w,nullptr,FALSE); return 0;
case WM_KEYDOWN:
if (wp==VK_ESCAPE) {
if(!c->editing) DestroyWindow(w); // Cancel region selection only.
@@ -1009,6 +1076,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(w,nullptr,FALSE);
}
if(!(GetKeyState(VK_CONTROL)&0x8000)) {
if(wp=='V') SendMessageW(w,WM_COMMAND,27,0);
if(wp=='T') SendMessageW(w,WM_COMMAND,26,0);
if(wp=='L') SendMessageW(w,WM_COMMAND,22,0);
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
@@ -1068,6 +1136,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
InvalidateRect(w,nullptr,FALSE); return 0;
}
c->selected=-1; c->selectedLabel=false;
if(c->pointer) { InvalidateRect(w,nullptr,FALSE); updateStatus(*c); return 0; }
}
if(c->editing && c->textTool) {
Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor;
+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;
+4
View File
@@ -13,6 +13,8 @@ struct Entry
std::wstring id, preview, app;
uint64_t bytes{}, at{};
bool pinned{}, image{}, files{};
int ocrState{}; // 0: legacy/not applicable, 1: pending, 2: saved, 3: failed
std::wstring ocrProfile = L"windows";
};
class History
{
@@ -27,6 +29,8 @@ class History
std::vector<Entry> entries() const;
Clip load(const std::wstring &id) const;
void add(const Clip &, const std::wstring &app, const Settings &);
bool saveOcr(const std::wstring &id, const std::optional<std::wstring> &, const Settings &);
std::optional<std::wstring> ocrText(const std::wstring &id, const std::wstring &profile = L"") const;
void remove(const std::wstring &);
void pin(const std::wstring &);
void clear();
+99 -2
View File
@@ -420,7 +420,7 @@ void search(HWND w, HistoryView *v)
auto generation = ++v->generation;
v->pending.clear();
auto *app = v->app;
app->enqueue(
app->enqueueHistorySearch(
[w, app, q, generation]
{
try
@@ -625,6 +625,8 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
int id = LOWORD(wp);
if (id == 101 && HIWORD(wp) == EN_CHANGE)
{
// Reject an older result even during the debounce interval.
++v->generation;
SetTimer(w, 1, 180, nullptr);
return 0;
}
@@ -732,7 +734,7 @@ LRESULT CALLBACK historyProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
AppendMenuW(menu, MF_STRING, 112, L"Pin / unpin");
AppendMenuW(menu, MF_STRING, 113, L"Delete");
AppendMenuW(menu, MF_STRING, 114, L"Open full item");
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (Windows OCR)");
AppendMenuW(menu, MF_STRING, 115, L"Copy text from image (OCR)");
POINT p;
GetCursorPos(&p);
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, p.x, p.y, 0, w, nullptr);
@@ -1057,6 +1059,99 @@ void showAutoOptions(HWND parent, SettingsView *v)
showRequestedWindow(w);
SetForegroundWindow(w);
}
struct OcrOptionsView { HWND parent; SettingsView *settings; };
Settings ocrDraft(HWND w, OcrOptionsView *v)
{
auto draft = v->settings->draft;
draft.useTesseract = checked(w, 501);
draft.tesseractPath = value(w, 502);
draft.tesseractLanguages = value(w, 503);
validateSettings(draft);
return draft;
}
LRESULT CALLBACK ocrOptionsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
auto *v = reinterpret_cast<OcrOptionsView *>(GetWindowLongPtrW(w, GWLP_USERDATA));
if (!v) return DefWindowProcW(w, msg, wp, lp);
if (msg == WM_COMMAND)
{
int id = LOWORD(wp);
try
{
if (id == 507)
{
if ((INT_PTR)ShellExecuteW(w, L"open", L"https://github.com/UB-Mannheim/tesseract/wiki#tesseract-installer-for-windows",
nullptr, nullptr, SW_SHOWNORMAL) <= 32)
throw std::runtime_error("Could not open the Tesseract download page in your browser");
return 0;
}
if (id == 504)
{
wchar_t path[32768]{};
OPENFILENAMEW picker{sizeof(picker)};
picker.hwndOwner = w; picker.lpstrFile = path; picker.nMaxFile = std::size(path);
picker.lpstrFilter = L"Tesseract executable (tesseract.exe)\0tesseract.exe\0Executables\0*.exe\0";
picker.Flags = OFN_FILEMUSTEXIST | OFN_NOCHANGEDIR;
if (GetOpenFileNameW(&picker)) value(w, 502, path);
return 0;
}
if (id == 505 || id == IDOK)
{
auto draft = ocrDraft(w, v);
if (id == 505)
{
draft.useTesseract = true;
validateTesseract(draft);
SetDlgItemTextW(w, 506, (L"Ready: " + draft.tesseractLanguages).c_str());
return 0;
}
validateTesseract(draft);
v->settings->draft.useTesseract = draft.useTesseract;
v->settings->draft.tesseractPath = draft.tesseractPath;
v->settings->draft.tesseractLanguages = draft.tesseractLanguages;
DestroyWindow(w);
return 0;
}
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
}
catch (const std::exception &e) { error(w, e); return 0; }
}
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
if (msg == WM_NCDESTROY)
{
EnableWindow(v->parent, TRUE);
SetForegroundWindow(v->parent);
disposeFont(w); delete v;
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
}
return DefWindowProcW(w, msg, wp, lp);
}
void showOcrOptions(HWND parent, SettingsView *v)
{
HWND w = makeWindow(*v->app, L"KSwitcher.OcrOptions", L"Image text recognition", ocrOptionsProc, 640, 450);
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)new OcrOptionsView{parent, v});
label(w, L"Windows OCR is used by default. Recognition stays on this computer.", 18, 16, 600);
checkbox(w, L"Use optional Tesseract 5 instead of Windows OCR", 501, 18, 48, 600);
check(w, 501, v->draft.useTesseract);
label(w, L"Tesseract executable (blank = detect installed location)", 18, 90, 600);
edit(w, 502, 18, 118, 484);
value(w, 502, v->draft.tesseractPath);
button(w, L"Browse…", 504, 514, 117, 106);
label(w, L"Languages used together (for example ukr+eng+rus)", 18, 160, 600);
edit(w, 503, 18, 188, 602);
value(w, 503, v->draft.tesseractLanguages);
button(w, L"Check installation", 505, 18, 228, 180);
control(w, L"STATIC", L"", 0, 506, 212, 232, 408, 24);
button(w, L"Download Tesseract…", 507, 18, 266, 180);
label(w, L"Opens the Windows installer page in your browser.", 212, 270, 408);
label(w, L"Changing the engine or languages reprocesses saved images in the background.", 18, 314, 602);
label(w, L"Click OK here, then Save in Settings to apply. No downloads are automatic.", 18, 344, 602);
button(w, L"OK", IDOK, 406, 390, 100);
button(w, L"Cancel", IDCANCEL, 520, 390, 100);
EnableWindow(parent, FALSE);
showRequestedWindow(w); SetForegroundWindow(w);
}
LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
if (msg == WM_DPICHANGED)
@@ -1092,6 +1187,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
showLogs(*v->app);
return 0;
}
if (id == 257) { showOcrOptions(w, v); return 0; }
if (id == 230)
{
collectLayout(w, v);
@@ -1685,6 +1781,7 @@ void showSettings(App &app, bool visible)
edit(w, 243, 18, 544, 742);
button(w, L"Auto-correction options…", 255, 18, 575, 240);
button(w, L"View log…", 256, 278, 575, 220);
button(w, L"OCR options…", 257, 518, 575, 242);
button(w, L"Import settings…", 250, 18, 606, 145);
button(w, L"Export settings…", 251, 173, 606, 145);
button(w, L"Snippets…", 252, 328, 606, 110);
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.5";
inline constexpr wchar_t AppVersion[] = L"0.6.8";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
struct UpdateResult
+28
View File
@@ -570,6 +570,34 @@ int main(int argc, char **argv) {
ks::showHistory(app,false);
auto history=app.historyWindow;
require(history&&!IsWindowVisible(history),"history layout tested without changing focus");
{
app.history=std::make_unique<ks::History>(temp/L"history-search");
app.history->add(ks::textClip(L"згенеруй приклад"),L"test",app.settings);
app.history->add(ks::textClip(L"unrelated entry"),L"test",app.settings);
// This App has no general worker: a search on that queue can never finish.
auto originalFocus=GetFocus();
auto pumpUntil=[&](int count) {
auto deadline=GetTickCount64()+3000;
do {
MSG message{};
while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
TranslateMessage(&message); DispatchMessageW(&message);
}
if(SendDlgItemMessageW(history,102,LB_GETCOUNT,0,0)==count) return true;
Sleep(5);
} while(GetTickCount64()<deadline);
return false;
};
ks::refreshHistory(app);
require(pumpUntil(2),"history loads independently of general worker queue");
SetDlgItemTextW(history,101,L"згенеруй");
require(pumpUntil(1),"typing filters history via debounce without focus change");
SetDlgItemTextW(history,101,L"missing query");
require(pumpUntil(0),"unmatched search clears history list");
SetDlgItemTextW(history,101,L"");
require(pumpUntil(2),"clearing search restores history");
require(GetFocus()==originalFocus,"history search preserves focus");
}
auto childRect=[&](int id) { RECT r{}; GetWindowRect(GetDlgItem(history,id),&r); MapWindowPoints(nullptr,history,(POINT*)&r,2); return r; };
RECT historyFrame{}; GetWindowRect(history,&historyFrame);
auto listBefore=childRect(102),searchBefore=childRect(101),buttonBefore=childRect(110);
+46 -4
View File
@@ -1,7 +1,14 @@
#include "ocr.h"
#include "app.h"
#include <iostream>
int main()
namespace ks {
struct InputRegressionAccess {
static void scheduleOcr(App &app) { app.scheduleHistoryOcr(app.settings); }
};
}
int main(int argc, char **argv)
{
const bool tesseract = argc == 2 && std::string(argv[1]) == "--tesseract";
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
try
{
@@ -24,7 +31,8 @@ int main()
auto oldFont = SelectObject(dc, font);
SetTextColor(dc, RGB(0, 0, 0));
SetBkColor(dc, RGB(255, 255, 255));
TextOutW(dc, 20, 40, L"HELLO KSWITCHER", 15);
std::wstring fixture = tesseract ? L"Привіт Україно HELLO" : L"HELLO KSWITCHER";
TextOutW(dc, 20, 40, fixture.c_str(), (int)fixture.size());
GdiFlush();
ks::Pixels pixels{800, 150, ks::Bytes((BYTE *)data, (BYTE *)data + 800 * 150 * 4)};
SelectObject(dc, oldFont);
@@ -36,11 +44,45 @@ int main()
auto decoded = ks::decodeImage(clip);
if (decoded.width != 800 || decoded.height != 150)
throw std::runtime_error("WIC decode size mismatch");
auto text = ks::recognizeText(clip);
ks::Settings config;
config.useTesseract = tesseract;
auto text = ks::recognizeText(clip, config);
std::cout << "OCR: " << ks::utf8(text) << '\n';
if (ks::upper(text).find(L"HELLO") == std::wstring::npos)
throw std::runtime_error("OCR fixture was not recognized");
std::cout << "PASS native Windows OCR, DIB decoding and async result retrieval\n";
if (tesseract && ks::lower(text).find(L"привіт") == std::wstring::npos)
throw std::runtime_error("Ukrainian OCR fixture was not recognized");
auto temp = std::filesystem::temp_directory_path() / (L"KSwitcher-auto-ocr-" + std::to_wstring(GetCurrentProcessId()));
auto sequence = GetClipboardSequenceNumber();
{
ks::App app(GetModuleHandleW(nullptr), true, temp);
app.settings.history = true;
app.history->add(clip, L"OCR fixture", app.settings);
auto id = app.history->entries().front().id;
if (tesseract)
{
app.history->saveOcr(id, L"old Windows result", app.settings);
app.settings.useTesseract = true;
}
ks::InputRegressionAccess::scheduleOcr(app);
auto deadline = GetTickCount64() + 35000;
while ((app.history->entries().front().ocrState == 1 ||
app.history->entries().front().ocrProfile != ks::ocrProfile(app.settings)) &&
GetTickCount64() < deadline) Sleep(20);
if (app.history->search(L"HELLO").size() != 1)
throw std::runtime_error("Background OCR did not make the image searchable");
ks::History restored(temp / L"history");
if (!restored.ocrText(id) || restored.search(L"HELLO").size() != 1)
throw std::runtime_error("Background OCR did not persist its result");
if (tesseract && (restored.search(L"привіт").size() != 1 || !restored.search(L"old Windows result").empty()))
throw std::runtime_error("Engine change did not replace cached Windows OCR with Ukrainian text");
if (GetClipboardSequenceNumber() != sequence)
throw std::runtime_error("Background OCR changed the clipboard");
std::cout << "PASS automatic background OCR, persisted search and unchanged clipboard\n";
}
std::filesystem::remove_all(temp);
std::cout << (tesseract ? "PASS optional Tesseract, mixed Ukrainian/English and cache migration\n" :
"PASS native Windows OCR, DIB decoding and async result retrieval\n");
CoUninitialize();
return 0;
}
+114 -2
View File
@@ -22,6 +22,8 @@ int main()
"editor is an independent Alt+Tab application window");
require(GetDlgItem(window,50)!=nullptr && c.statusHeight>0,"status bar is created");
require(c.pencilCursor!=nullptr,"pencil drawing cursor loads");
require(c.freehand && !c.pointer && ks::toolCursor(c)==c.pencilCursor,
"new screenshot editor starts with the pencil tool");
for(int size:{32,48,64,96}) {
auto cursor=(HCURSOR)LoadImageW(app.instance,MAKEINTRESOURCEW(IDC_SCREENSHOT_PENCIL),IMAGE_CURSOR,size,size,0);
ICONINFO info{};
@@ -31,16 +33,58 @@ int main()
if(info.hbmColor) DeleteObject(info.hbmColor);
DestroyCursor(cursor);
}
ks::Surface cursorPreview(784,448);
PatBlt(cursorPreview.dc,0,0,784,448,WHITENESS);
int row=0;
for(int size:{32,48,64,96}) {
int column=0;
for(int tool:ks::CursorTools) {
auto cursor=ks::makeToolCursor(tool,size); ICONINFO info{};
require(cursor && GetIconInfo(cursor,&info) && !info.fIcon,"tool cursors support standard and high DPI");
require(info.xHotspot==UINT(MulDiv(7,size,32)) && info.yHotspot==info.xHotspot,"tool cursor hotspot stays on crosshair center");
BITMAP mask{}; GetObjectW(info.hbmMask,sizeof(mask),&mask);
require(mask.bmWidth==size && mask.bmHeight==size*2,"cursor transparency mask matches requested DPI");
if(column%2) {
RECT tile{column*112,row*112,(column+1)*112,(row+1)*112};
auto dark=CreateSolidBrush(RGB(40,40,40)); FillRect(cursorPreview.dc,&tile,dark); DeleteObject(dark);
}
DrawIconEx(cursorPreview.dc,column*112,row*112,cursor,size,size,0,nullptr,DI_NORMAL);
if(info.hbmMask) DeleteObject(info.hbmMask);
if(info.hbmColor) DeleteObject(info.hbmColor);
DestroyCursor(cursor); ++column;
}
++row;
}
auto dib=ks::screenshotClip(cursorPreview.dc,784,448,{}).formats[0].data;
BITMAPFILEHEADER bmp{}; bmp.bfType=0x4d42; bmp.bfOffBits=sizeof(bmp)+sizeof(BITMAPINFOHEADER); bmp.bfSize=DWORD(sizeof(bmp)+dib.size());
ks::Bytes preview(sizeof(bmp)); memcpy(preview.data(),&bmp,sizeof(bmp)); preview.insert(preview.end(),dib.begin(),dib.end());
ks::atomicWrite(L"build/cursor-preview.bmp",preview);
for(int tool:{27,26,21,10,11,22,23,24,25,19}) {
SendMessageW(window,WM_COMMAND,tool,0);
HCURSOR expected=nullptr;
if(tool==27) expected=LoadCursorW(nullptr,IDC_ARROW);
else if(tool==26) expected=LoadCursorW(nullptr,IDC_IBEAM);
else if(tool==21) expected=c.pencilCursor;
else for(size_t i=0;i<std::size(ks::CursorTools);++i) if(ks::CursorTools[i]==tool) expected=c.drawingCursors[i];
require(expected && ks::actionCursor(c,{-1,ks::MarkPart::None},true)==expected,"each tool uses its own drawing cursor");
require(ks::actionCursor(c,{-1,ks::MarkPart::None},false)==LoadCursorW(nullptr,IDC_ARROW),"outside canvas uses normal pointer");
c.marks={{{20,20},{120,20},0}};
require(ks::actionCursor(c,{0,ks::MarkPart::Body},true)==LoadCursorW(nullptr,IDC_SIZEALL),"move cursor overrides drawing tool");
require(ks::actionCursor(c,{0,ks::MarkPart::Rotation},true)==LoadCursorW(nullptr,IDC_HAND),"rotation cursor overrides drawing tool");
require(ks::actionCursor(c,{0,ks::MarkPart::End},true)==LoadCursorW(nullptr,IDC_SIZEWE),"endpoint resize overrides drawing tool");
require(ks::actionCursor(c,{0,ks::MarkPart::Thickness},true)==LoadCursorW(nullptr,IDC_SIZENS),"thickness resize is perpendicular to line");
}
c.marks.clear(); SendMessageW(window,WM_COMMAND,27,0);
wchar_t windowTitle[128]{}; GetWindowTextW(window,windowTitle,128);
require(std::wstring(windowTitle)==L"KSwitcher — Screenshot #1","title includes editor index without usage hints");
RECT client{}; GetClientRect(window,&client);
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
ks::updateStatus(c);
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"default tool hint is shown");
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"default tool hint is shown");
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"button leave restores contextual hint");
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"button leave restores contextual hint");
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
@@ -144,6 +188,74 @@ int main()
auto outside=c.marks[2].to; outside.x-=4;
require(ks::markAt(c,outside)==std::pair{2,ks::MarkPart::End},"endpoint remains draggable outside badge");
}
{
auto mouse=[&](UINT message,POINT p) {
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
LONG y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
};
SendMessageW(window,WM_COMMAND,27,0); c.marks.clear();
mouse(WM_LBUTTONDOWN,{40,160}); mouse(WM_MOUSEMOVE,{100,190}); mouse(WM_LBUTTONUP,{100,190});
require(c.marks.empty() && !c.dragging && c.selected==-1,"pointer drag on empty canvas never adds an object");
for(int tool : {11,22}) for(int target : {0,1,2}) {
if(target==0) c.marks={{{55,160},{55,160},1},{{255,160},{255,160},2}};
else {
c.marks={{{30,140},{80,190},0},{{230,140},{280,190},0}};
if(target==1) for(auto &mark:c.marks) mark.shape=ks::ScreenshotShape::Rectangle;
else for(auto &mark:c.marks) mark.from.y=mark.to.y=160;
}
SendMessageW(window,WM_COMMAND,tool,0);
BYTE savedKeys[256]{},drawingKeys[256]{};
require(GetKeyboardState(savedKeys)!=FALSE,"read thread keyboard state");
std::copy(savedKeys,savedKeys+256,drawingKeys); drawingKeys[VK_CONTROL]=0x80;
require(SetKeyboardState(drawingKeys)!=FALSE,"set thread-local Ctrl for drawing test");
mouse(WM_LBUTTONDOWN,{55,160}); mouse(WM_MOUSEMOVE,{255,160}); mouse(WM_LBUTTONUP,{255,160});
SetKeyboardState(savedKeys);
require(c.marks.size()==3,"drawing over existing objects creates a connector with Ctrl");
auto &connector=c.marks[2];
require(target==0?(connector.fromNumber==1 && connector.toNumber==2):
(connector.fromLink.target==c.marks[0].id && connector.toLink.target==c.marks[1].id),
"new arrow or line automatically attaches both endpoints to targets");
require(connector.ends==(tool==11?c.arrowEnds:ks::LineEnds::None),"arrow and straight-line tools retain their end styles");
auto before=connector.from;
SendMessageW(window,WM_KEYDOWN,'V',0);
require(c.pointer && !c.arrow && !c.freehand,"V selects pointer mode");
POINT body=target==0?POINT{55,160}:target==1?POINT{30,175}:POINT{30,160};
mouse(WM_LBUTTONDOWN,body); mouse(WM_MOUSEMOVE,{body.x,body.y-20}); mouse(WM_LBUTTONUP,{body.x,body.y-20});
require(c.selected==0 && c.marks.size()==3 && c.marks[2].from.y<before.y,
"pointer moves the target and the new connector follows without adding objects");
mouse(WM_LBUTTONDOWN,{150,220}); mouse(WM_LBUTTONUP,{150,220});
require(c.selected==-1 && c.marks.size()==3,"pointer click on empty space only clears selection");
}
}
for(bool manualAngle : {false,true}) {
c.marks={{{40,140},{180,60},0}}; c.selected=0;
c.marks[0].fontSize=18; c.marks[0].fontWeight=FW_BOLD;
c.marks[0].rotation=31; c.marks[0].labelRotationManual=manualAngle;
SendMessageW(window,WM_COMMAND,35,0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Sloped caption");
ks::finishInlineText(window,c,true);
auto captionAngle=ks::screenshotArrowLabel(c.marks[0]).rotation;
require(captionAngle!=0,"caption retains its automatic or manual angle");
SendMessageW(window,WM_COMMAND,26,0);
LONG x=c.view.left+MulDiv(220,c.view.right-c.view.left,c.image->width);
LONG y=c.view.top+MulDiv(190,c.view.bottom-c.view.top,c.image->height);
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(x,y));
require(c.inlineText && !c.editingLabel,"standalone text starts independently of caption");
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Independent text");
ks::finishInlineText(window,c,true);
require(c.marks.size()==2 && c.marks[1].isText() && c.marks[1].rotation==0,
"new standalone text does not inherit caption rotation");
require(c.marks[1].fontSize==18 && c.marks[1].fontWeight==FW_BOLD,
"new text still inherits font styling");
require(ks::screenshotArrowLabel(c.marks[0]).rotation==captionAngle,"new text does not change caption angle");
c.marks[1].rotation=22; c.selected=1;
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited standalone text");
ks::finishInlineText(window,c,true);
require(c.marks[1].rotation==22,"editing existing rotated text preserves its own angle");
SendMessageW(window,WM_COMMAND,27,0);
}
std::vector<ks::Mark> nudgeMarks;
nudgeMarks.push_back({{60,60},{60,60},1});
nudgeMarks.push_back({{60,60},{120,100},0});
+69
View File
@@ -468,6 +468,75 @@ int main(int argc, char **argv)
history.clear();
require(history.entries().empty(), "clear history");
}
{
ks::History history(temp / L"search");
history.add(ks::textClip(std::wstring(260, L'x') + L" hidden needle"), L"Test", settings);
history.add(ks::textClip(L"first\nsecond\tthird"), L"Test", settings);
require(history.search(L"hidden needle").size() == 1, "search beyond truncated preview");
require(history.search(L"first\nsecond").size() == 1, "search retains original newlines");
require(history.search(L"second\tthird").size() == 1, "search retains original tabs");
require(history.search(L"absent").empty(), "short previews reject unmatched queries");
require(history.search(L"FIRST").size() == 1, "short preview search ignores case");
}
{
auto folder = temp / L"ocr-history";
ks::History history(folder);
ks::Clip image{{ks::imageDib(ks::Pixels{2, 2, ks::Bytes(16, 255)})}};
history.add(image, L"OCR test", settings);
auto id = history.entries().front().id;
require(history.entries().front().ocrState == 1, "new images await background OCR");
require(ks::History(folder).entries().front().ocrState == 1, "pending OCR survives restart");
auto original = ks::encodeClip(history.load(id));
auto beforeBytes = history.entries().front().bytes;
require(history.saveOcr(id, L"Розпізнаний текст HELLO", settings), "save image OCR");
require(history.entries().front().bytes > beforeBytes, "OCR storage counts against history size");
require(ks::encodeClip(history.load(id)) == original, "OCR preserves original image formats");
require(!history.saveOcr(id, L"replacement", settings), "completed OCR is not overwritten");
ks::History restored(folder);
require(restored.search(L"розпізнаний").size() == 1, "persisted Ukrainian OCR is searchable");
require(restored.ocrText(id) == L"Розпізнаний текст HELLO", "OCR text survives restart");
auto raw = ks::readFile(folder / (id + L".ocr"), 2 * 1024 * 1024);
require(std::string(raw.begin(), raw.end()).find("HELLO") == std::string::npos, "OCR sidecar is encrypted");
restored.remove(id);
require(!std::filesystem::exists(folder / (id + L".ocr")), "deleting image removes OCR sidecar");
require(!restored.saveOcr(id, L"late result", settings), "late OCR cannot resurrect deleted image");
restored.add(image, L"OCR test", settings);
id = restored.entries().front().id;
restored.saveOcr(id, L"", settings);
require(restored.ocrText(id).has_value() && restored.ocrText(id)->empty(), "empty OCR is a completed result");
restored.clear();
require(!std::filesystem::exists(folder / (id + L".ocr")), "clear removes OCR sidecars");
restored.add(image, L"OCR test", settings);
id = restored.entries().front().id;
restored.saveOcr(id, std::nullopt, settings);
require(ks::History(folder).entries().front().ocrState == 3, "failed OCR is not retried on every search or restart");
restored.saveOcr(id, L"manual retry", settings);
require(restored.search(L"manual retry").size() == 1, "explicit retry can recover failed OCR");
restored.add(ks::textClip(L"newer"), L"test", settings);
auto limited = settings; limited.maxItems = 1;
restored.add(ks::textClip(L"newest"), L"test", limited);
require(!std::filesystem::exists(folder / (id + L".ocr")), "pruning image removes OCR sidecar");
}
{
ks::Settings options;
require(!options.useTesseract, "Tesseract is opt-in by default");
options.useTesseract = true;
options.tesseractPath = L"C:\\Program Files\\Tesseract-OCR\\tesseract.exe";
options.tesseractLanguages = L"ukr+eng+rus";
auto restored = ks::decodeSettings(ks::encodeSettings(options), {});
require(restored.useTesseract && restored.tesseractPath == options.tesseractPath &&
restored.tesseractLanguages == options.tesseractLanguages, "OCR settings roundtrip");
options.tesseractLanguages = L"ukr+../eng";
rejects([&] { ks::validateSettings(options); }, "OCR language paths rejected");
options.tesseractLanguages = L"ukr++eng";
rejects([&] { ks::validateSettings(options); }, "empty OCR language rejected");
options.tesseractLanguages = L"ukr+eng";
options.tesseractPath = L"C:\\missing-tesseract\\tesseract.exe";
rejects([&] { ks::validateTesseract(options); }, "missing optional Tesseract reports clear failure");
options.useTesseract = false;
ks::validateTesseract(options);
require(true, "Windows OCR works without optional installation");
}
// Test-owned directory only; never touch the user's clipboard or stored history.
std::filesystem::remove_all(temp);
CoUninitialize();