Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
build\Release\CantoIME-SG.dll, `
build\Release\traditional.txt, `
build\Release\simplified.txt, `
build\Release\canto-dictd.exe, `
installer\Output\CantoIME-Setup.exe dist\

- name: Upload artifact
Expand Down
22 changes: 22 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ endif()
set(CANTO_SOURCES
src/Globals.cpp
src/Dictionary.cpp
src/CandidateEngine.cpp
src/DictClient.cpp
src/CandidateWindow.cpp
src/TextService.cpp
src/LangBarButton.cpp
Expand Down Expand Up @@ -131,3 +133,23 @@ add_canto_variant(TW) # Taiwan (Traditional)
add_canto_variant(MO) # Macao SAR (Traditional)
add_canto_variant(CN) # China (Simplified)
add_canto_variant(SG) # Singapore (Simplified)

# The dictionary service (canto-dictd.exe): a per-user, windowless resident
# process that loads the dictionary once and answers candidate-resolution
# requests from the IME over a named pipe. WIN32 => no console window. Compiled
# with a default variant define (only affects the stock-dictionary default before
# the user has chosen one; the IME's SETDICT is authoritative). Built into the
# same Release folder so the installer and CI staging can pick it up.
add_executable(canto-dictd WIN32
src/DictService.cpp
src/Dictionary.cpp
src/CandidateEngine.cpp
src/Settings.cpp)
set_target_properties(canto-dictd PROPERTIES OUTPUT_NAME "canto-dictd")
target_include_directories(canto-dictd PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_compile_definitions(canto-dictd PRIVATE
UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX CANTO_VARIANT_HK)
target_link_libraries(canto-dictd PRIVATE advapi32 ole32 uuid)
if(MSVC)
target_compile_options(canto-dictd PRIVATE /W4 /permissive-)
endif()
33 changes: 33 additions & 0 deletions installer/setup.iss
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,22 @@ Source: "..\build\Release\CantoIME-SG.dll"; DestDir: "{app}"; Check: IsVariant('
; reads; simplified.txt ships for future use. Removed with the app on uninstall.
Source: "..\traditional.txt"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\simplified.txt"; DestDir: "{app}"; Flags: ignoreversion
; The dictionary service. A per-user resident process that loads the dictionary
; once and answers the IME's lookup requests over a named pipe, so switching into
; the IME does not re-parse the dictionary. Removed with the app on uninstall.
Source: "..\build\Release\canto-dictd.exe"; DestDir: "{app}"; Flags: ignoreversion 64bit restartreplace uninsrestartdelete

[Registry]
; Clean up the per-user options key on uninstall (not created at install time;
; the IME writes it when the user changes options).
Root: HKCU; Subkey: "Software\CantoIME"; Flags: uninsdeletekey dontcreatekey
; Start the dictionary service at every user's logon, running as that user. It
; starts early (before the first switch into the IME) but self-gates: it stays
; and loads the dictionary only if that user has actually added a CantoIME
; keyboard, and exits immediately otherwise.
Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
ValueName: "CantoIME Dictionary Service"; ValueData: """{app}\canto-dictd.exe"""; \
Flags: uninsdeletevalue

[Run]
; Register the selected variant's TSF text service via the 64-bit regsvr32.
Expand All @@ -80,8 +91,19 @@ Filename: "{sys}\regsvr32.exe"; Parameters: "/s ""{app}\CantoIME-CN.dll"""; \
Filename: "{sys}\regsvr32.exe"; Parameters: "/s ""{app}\CantoIME-SG.dll"""; \
Check: IsVariant('SG'); StatusMsg: "Registering Singapore (Simplified) input method..."; \
Flags: runhidden waituntilterminated
; Start the dictionary service now (as the original, non-elevated user, so its
; per-user pipe and keyboard check match the IME). It self-gates: on a fresh
; install where the keyboard is not yet added it just exits; on an update where
; the keyboard was already added it stays and serves. Either way the logon
; autostart brings it back on subsequent sessions.
Filename: "{app}\canto-dictd.exe"; \
StatusMsg: "Starting dictionary service..."; \
Flags: nowait runhidden runasoriginaluser

[UninstallRun]
; Stop the resident dictionary service first, so its executable can be removed.
Filename: "{sys}\taskkill.exe"; Parameters: "/f /im canto-dictd.exe"; \
Flags: runhidden; RunOnceId: "StopDictd"
; Unregister BEFORE files are removed. Only the variant DLL actually present in
; {app} gets unregistered (exactly the one that was installed).
Filename: "{sys}\regsvr32.exe"; Parameters: "/u /s ""{app}\CantoIME-HK.dll"""; \
Expand Down Expand Up @@ -210,6 +232,17 @@ begin
Result := SelectedVariant() = Tag;
end;

procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
begin
{ Before files are copied, stop any running (previous) dictionary service so an
update can replace its executable rather than deferring to a reboot. }
if CurStep = ssInstall then
Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im canto-dictd.exe', '',
SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;

function DllPresent(Name: String): Boolean;
begin
Result := FileExists(ExpandConstant('{app}\') + Name);
Expand Down
120 changes: 120 additions & 0 deletions src/CandidateEngine.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// CandidateEngine.cpp
//
// Extracted verbatim from the former CTextService::BuildCandidates. See the long
// comment below for the segmentation/ordering rules.

#include "CandidateEngine.h"
#include "Dictionary.h"

#include <unordered_set>
#include <functional>
#include <algorithm>

static const size_t kMaxCandidates = 300;
static const size_t kMaxPrefixKeys = 200;

// The buffer is split into a run of leading exact keys (each locked to its best
// character) followed by a non-empty final segment that is prefix-matched. Every
// such split is enumerated, then ordered by its per-key consumption sequence
// (leading key lengths, then the final segment's length) compared largest-first.
// So for "abcdefg" with keys abc/def/g/abcd/efg, "abcd efg" (lengths 4,3) beats
// "abc def g" (lengths 3,3,1) because its first key is longer, not because it
// uses fewer keys. The 0-leading-key split is the whole buffer prefix-matched:
// its first key consumes all n characters, the maximum possible, so a real key
// spanning the buffer still ranks first, e.g. "hah" surfaces 哈哈 (key "haha")
// before 下下 (a doubling of "ha").
//
// Each split emits one entry per character of each prefix-matched key, displayed
// as <locked chars><char><leftover romanization> and committed as <locked chars>
// <char>. All splits share one dedup set keyed on the commit string, so the first
// (greediest) segmentation to produce a given Chinese text wins and later
// duplicates drop.
std::vector<EngineCandidate> BuildCandidates(const Dictionary& dict,
const std::wstring& romanization)
{
std::vector<EngineCandidate> candidates;
if (romanization.empty())
return candidates;

const std::wstring& buf = romanization;
const size_t n = buf.size();

std::unordered_set<std::wstring> seenCommits;

// Emits candidates for one (locked prefix, open final segment) split.
// Returns false once the candidate cap is reached so callers stop early.
auto emit = [&](const std::wstring& locked, const std::wstring& finalSeg) -> bool
{
std::vector<std::wstring> keys = dict.KeysWithPrefix(finalSeg, kMaxPrefixKeys);
for (const std::wstring& key : keys)
{
std::wstring remainder = key.substr(finalSeg.size()); // "" for exact match
for (const std::wstring& ch : dict.Lookup(key))
{
std::wstring commit = locked + ch;
if (!seenCommits.insert(commit).second)
continue; // already emitted this text via a greedier split
EngineCandidate c;
c.display = commit + remainder;
c.commit = std::move(commit);
candidates.push_back(std::move(c));
if (candidates.size() >= kMaxCandidates)
return false;
}
}
return true;
};

// ---- Enumerate every split: partition a prefix [0, consumed) of the buffer
// into leading exact keys (locked to their best character), leaving a
// non-empty final segment [consumed, n) to prefix-match. `seq` is the split's
// consumption profile: the leading key lengths followed by the final segment
// length (always summing to n). ----
struct Split { std::wstring locked; size_t consumed; std::vector<size_t> seq; };
std::vector<Split> splits;
const size_t kMaxSplits = 256; // safety valve against pathological fan-out

std::function<void(size_t, const std::wstring&, const std::vector<size_t>&)> gather =
[&](size_t pos, const std::wstring& locked, const std::vector<size_t>& lens)
{
if (splits.size() >= kMaxSplits)
return;
std::vector<size_t> seq = lens;
seq.push_back(n - pos); // the final segment's length
splits.push_back({ locked, pos, std::move(seq) }); // pos < n

// Extend by one more leading exact key, keeping the final segment
// non-empty (pos + len < n). Lengths are bounded by the longest exact
// key at this position.
size_t maxLen = dict.LongestExactPrefixLen(buf, pos);
for (size_t len = 1; len <= maxLen && pos + len < n; ++len)
{
std::wstring key = buf.substr(pos, len);
const auto& chars = dict.Lookup(key);
if (chars.empty())
continue; // not an exact key at this length
std::vector<size_t> nextLens = lens;
nextLens.push_back(len);
gather(pos + len, locked + chars.front(), nextLens);
}
};
gather(0, std::wstring(), std::vector<size_t>());

// Greediest consumption first: compare the length sequences element by
// element, larger length wins. stable_sort keeps discovery order on ties.
std::stable_sort(splits.begin(), splits.end(),
[](const Split& a, const Split& b)
{
size_t m = std::min(a.seq.size(), b.seq.size());
for (size_t i = 0; i < m; ++i)
if (a.seq[i] != b.seq[i])
return a.seq[i] > b.seq[i];
return a.seq.size() < b.seq.size();
});

for (const Split& s : splits)
if (!emit(s.locked, buf.substr(s.consumed)))
break;

return candidates;
}
28 changes: 28 additions & 0 deletions src/CandidateEngine.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// CandidateEngine.h
//
// The pure romanization -> candidate logic, factored out of the text service so
// it can run wherever the dictionary lives: in the dictionary service process
// (canto-dictd) that owns the loaded dictionary, and in the in-process fallback
// the IME uses if the service is unavailable. Depends only on a Dictionary and
// the typed romanization buffer; no TSF or UI state.

#pragma once

#include <string>
#include <vector>

class Dictionary;

// One candidate. `display` is what the candidate window shows (may include a
// trailing romanization remainder, e.g. L"高o"); `commit` is the Chinese text
// inserted when it is accepted (L"高").
struct EngineCandidate
{
std::wstring display;
std::wstring commit;
};

// Builds the ordered, de-duplicated candidate list for a romanization buffer
// against the given dictionary. Empty buffer -> empty result.
std::vector<EngineCandidate> BuildCandidates(const Dictionary& dict,
const std::wstring& romanization);
Loading
Loading