This guide describes the dictionary implementation currently shipped on this branch. For installation and use, see dictionary.md.
| File | Required | Purpose |
|---|---|---|
.dict |
Yes | Uncompressed definition data |
.idx |
Yes | Sorted headwords and offsets into .dict |
.ifo |
Recommended | Metadata and sametypesequence used to interpret definition fields |
.syn |
Optional | Alternate forms mapped to .idx ordinals |
.idx.oft / .syn.oft |
Optional | Coarse page offsets used to narrow a scan |
.idx.oft.cspt / .syn.oft.cspt |
Optional | CrossInk prefix indexes used as the fastest lookup path |
.qidx |
Generated | Disposable sampled index built by the device when .idx has no prepared accelerator |
The device requires an uncompressed .dict; it does not read .dict.dz or .syn.dz directly. Use scripts/dictionary_tools.py prep on a computer when decompression is needed or to generate the fastest .oft/.cspt accelerators. For an uncompressed dictionary without those accelerators, the device automatically generates .qidx on first lookup.
Dictionary discovery is implemented by DictionaryRegistry. It checks /.dictionaries first and /dictionaries second, using only the first root directory found. Each child folder must contain exactly one .idx, no more than one .ifo, and a .dict file. Hidden folders and ambiguous folders are skipped.
Dictionary::locate searches .idx in this order:
- Use
.idx.oft.csptto select a small byte range. - Fall back to
.idx.oftto select an offset page. - Fall back to the device-generated
.qidxsampled index. - Fall back to scanning
.idxfrom the beginning if the sidecar cannot be read or written.
Dictionary::resolveAltForm uses .syn.oft.cspt, then .syn.oft, then a full .syn scan. Dictionary::findSimilar uses .idx.oft when available and otherwise uses .qidx to scan a bounded neighborhood. If neither index accelerator is usable, spelling suggestions are skipped so a large dictionary cannot block the reader UI; direct lookup still works with the uncompressed .dict and .idx files.
.qidx uses a 20-byte little-endian header containing QIDX, format version, sample interval, sample count, and source .idx size, followed by the byte offset of every 256th .idx entry. It is written through a temporary file and installed only after the complete scan succeeds. A size mismatch or invalid header causes it to be rebuilt.
StarDict files with idxoffsetbits=64 are parsed, but entries whose definition offset exceeds the device's supported 32-bit range fail safely.
DictionaryDefinitionActivity resolves the selected .dict byte range and renders one page at a time. DictHtmlRenderer streams HTML input, while DictLayout::Wrapper wraps styled spans into page lines. Keeping only the current page bounds peak RAM and avoids materializing a large definition as one in-memory document. Definition body text uses the active reader font; headers and controls keep built-in UI fonts. SD-font text is passed through unchanged. Built-in coverage uses the audited, fixed Lexend Deca/Bitter glyph set (including the renderer's Γ, ε, and ω fallbacks), while unsupported IPA, Greek, combining-mark, and punctuation codepoints use the existing approximations.
Paging re-parses the definition from its start. This trades extra sequential reads for predictable memory usage on the ESP32-C3.
Chained lookups use LookupChain, which stores compact history positions and page numbers instead of owned copies of every headword. The chain is bounded by LookupHistory::MAX_VISIBLE_ENTRIES (currently 50).
Each EPUB cache stores history at <cachePath>/dictionary_history.txt. Lines use word|STATUS, where the status is direct, stemmed, alternate form, suggestion, or not found.
The file is append-only and is not automatically truncated. The UI loads only the newest 50 entries. Cache-clear helpers preserve this file as user state.
The standard-library-only tool is scripts/dictionary_tools.py:
# Decompress .dict.dz/.syn.dz and generate .oft/.cspt files.
python3 scripts/dictionary_tools.py prep /path/to/dictionary-folder
# Perform an exact lookup.
python3 scripts/dictionary_tools.py lookup /path/to/dictionary-folder apple
# Merge prepared dictionaries.
python3 scripts/dictionary_tools.py merge \
--source /path/to/dict-a \
--source /path/to/dict-b \
--output /path/to/merged-dictlookup and merge require an uncompressed .dict. merge writes a prepared output including applicable .oft and .cspt files.
Dictionary definitions use the active reader font, so the SD-card font catalog
also has a dictionary-specific build. The
lib/EpdFont/scripts/build-dictionary-fonts.py wrapper uses the family, style,
and size catalog in lib/EpdFont/scripts/sd-fonts.yaml, then adds the broad
IPA, combining-mark, and reader ranges needed by dictionary definitions before
delegating to build-sd-fonts.py. It packages each generated family as a ZIP.
Install the font-builder dependencies and run it from the CrossInk repository root:
python3 -m pip install -r lib/EpdFont/scripts/requirements.txt
python3 lib/EpdFont/scripts/build-dictionary-fonts.py --clean --jobs 2If you are generating your own dictionary fonts, change the output directory so your files do not mix with the shared catalog output. For example:
python3 lib/EpdFont/scripts/build-dictionary-fonts.py \
--output-dir ./generated-dictionary-fontsThe --clean option removes the selected output directory before building, so
do not use it with the default location unless you intend to rebuild that
catalog.
By default, the generated family folders and ZIPs are written to
../crossink-fonts/dictionary-fonts. Unzip a family archive into /.fonts/ or
/fonts/ on the SD card, or copy the output to the sibling crossink-fonts
repository when publishing the catalog. Use --output-dir to choose another
destination.
Useful options:
| Option | Purpose |
|---|---|
--only FamilyA,FamilyB |
Build only the named families from sd-fonts.yaml |
--config path/to/catalog.yaml |
Use a different family catalog |
--output-dir path |
Write family folders and ZIPs somewhere other than the default |
--clean |
Remove the output directory before building, avoiding stale .cpfont files |
--jobs N / -j N |
Limit parallel family builds |
--timeout SECONDS |
Set the per-family converter timeout (default: 600) |
--verbose / -v |
Stream converter output while debugging a build |
The wrapper does not change sd-fonts.yaml; it creates a temporary transformed
catalog for the shared builder. If a family is changed or removed, use
--clean so stale files cannot be mistaken for current output.
Both .idx.oft.cspt and .syn.oft.cspt use the same format:
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Magic CSPT |
| 4 | 1 | Version (1) |
| 5 | 1 | Prefix length (16) |
| 6 | 2 | Producer stride (16, little-endian) |
| 8 | 4 | Entry count (little-endian) |
| 12... | 20 each | 16-byte, zero-padded prefix plus 4-byte source offset |
The reader performs a case-insensitive binary search for the last prefix less than or equal to the target, then scans only until the next recorded source offset. Invalid or missing .cspt data falls back to .oft, then to a full scan.
The header's stride field is currently informational. Producers must continue to emit 16 until the format version and readers are updated together.
There is no dictionary-specific host fixture suite in this branch. For changes:
- Run
python3 scripts/dictionary_tools.py prepandlookupagainst a representative StarDict dictionary. - Build the simulator with
pio run -e simulatorfor reader/UI integration. - On hardware, test dictionaries with and without
.oft/.cspt, a.syndictionary, HTML definitions, long definitions, lookup history, chained lookup, and per-book overrides.
Multi-word selection is limited to the currently rendered page. Reducing the reader or definition font size can fit more of a phrase on one page.