- An OCONV-written suggestion (colour’s) is mapped to the run's English
spelling: the dialect lists are read with ', and the result keeps the
dictionary's apostrophe (color’s). Straight and curly duplicates (color's,
color’s) are one suggestion. Test: curly_suggestions_are_mapped_to_the_dialect.
- Conv::at looks up each prefix of the text (at most MAX_CONV_BYTES) in a
hash map instead of scanning the pairs under its first character: 65,536
pairs on one character cost 64 lookups per position, not a scan (Korean's
OCONV has 588 pairs on one character, so a per-character cap would not
fit). Test: a_huge_conversion_table_on_one_character_stays_fast.
- The reviewers' OCONV repros are a test (oconv_suggestions_match_hunspell):
OCONV applies after a suggestion's case is set, case-sensitively, as the
hunspell 1.7.5 CLI answers (KBT → KAT; Kbt → Kat under k → q).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
48ed6f8's lenient English path ignored OCONV because craft-proof couldn't
read it. Now that it can (CP6.4), English reads it like every language:
suggestions are shown as the dictionary writes them (`don’t` with the
usual en_US/en_GB `OCONV ' ’`), as Hunspell shows them, and ICONV reads
them back. Compounding is still ignored for English.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
- OCONV (output conversion, as Hunspell 1.7 applies it): read from the
.aff and the compact format, applied to suggestions as they are shown
(`AffixDict::output`); what is correct doesn't change. The French
(Grammalecte) dictionary writes `’` for the `'` its stems use.
- ICONV and OCONV tables hold at most 65,536 pairs of at most 64 bytes a
side (the Korean tables hold 11,172); more is an error. Each table is
indexed by the first character of its `from` texts, so converting a word
looks at the few pairs that can start at each character instead of
scanning the whole table per character (review of storytold/wordcraft#5000).
- Input longer than MAX_INPUT_BYTES (4 × MAX_WORD_BYTES) is no word and
is no longer converted before the length check rejects it (review of
storytold/wordcraft#5000); input made long by IGNORE characters within
that bound still checks.
- Longest match wins, and of equal ones the later pair, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Every case checked black-box against the hunspell 1.7.5 CLI on the
reviewers' repro files; checker and forms() changed together:
- NEEDAFFIX belongs to its homonym: with foo/NA and foo/B (or a plain foo),
foo is right.
- A prefix's flag may come from the suffix's continuation classes, with
CIRCUMFIX (PFX leg/X + SFX obb/AX: legnagyobb, legeslegnagyobb) or with a
NEEDAFFIX prefix (PFX leg/N + SFX obb/A: legnagyobb).
- The last suffix of a chain may carry NEEDAFFIX (met by the suffix under
it) or CIRCUMFIX (Hunspell ignores it there): work/A, able/B, s/N gives
workables. chained_forms_follow_needaffix_circumfix_and_fullstrip asserted
the opposite and is corrected.
- A prefix and a suffix that both need another affix make no word
(re/N + s/N: reworks is wrong).
Test needaffix_circumfix_and_fullstrip_match_hunspell holds all 32 repro
pairs with Hunspell's answer for each prefix/stem/suffix combination, and
checks that forms() lists exactly the right words. A circumfix prefix with
two suffixes (neworkables) is not supported: its test is #[ignore]d with
the reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
From the rebase onto craft-libs#483: Hunspell rejects a compound with a
NEEDAFFIX part (checked black-box with the hunspell CLI: foo/a + bar/bN under
COMPOUNDRULE ab, foobar is wrong), so compound() skips such stems as it does
FORBIDDENWORD ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Random stems and words through a dictionary that uses all three
directives (whole-stem strips, empty forms, circumfix pairs, forms that
need another affix): no panic, and every form forms() lists is non-empty
and accepted by the checker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
forms() tested a prefix rule's condition on the stem only, then added
the prefix to every cross-product suffix form as well. contains() tests
the condition on what the prefix comes off: the stem for the prefix
alone, the suffix form in a cross product. So forms() could list a
cross product the checker rejects (q over an empty suffix form under
the condition x) and miss one it accepts.
A small helper, prefixed(), now adds a prefix the way the checker takes
it back off, for the stem and for each cross-product suffix form. The
tests cover a cross product that meets the condition and one that does
not. Over the whole hunspell-lv lv_LV dictionary the listed forms are
unchanged (4,515,511, all accepted).
Ported from storytold/wordcraft#5012 (8d84a49f5) onto craft-proof.
Adapted to flag ids.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
The previous commit skipped an empty suffix form altogether, so forms()
no longer listed what a continuation class or a prefix adds to it,
although contains() accepts it: with FULLSTRIP, x/A, SFX A x 0/B and
SFX B 0 y, contains("y") is true but forms("x") left "y" out.
An empty form is now only kept out of the list. It still goes into the
cross-product list and the chain queue, as before the previous commit.
The new test covers the chain case and checks that every form listed
for a prefix over an empty suffix form is accepted.
Ported from storytold/wordcraft#5012 (6fa95a695) onto craft-proof.
Adapted to flag ids.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Two more cases the forms() checks missed.
- With FULLSTRIP a suffix can strip a stem down to nothing, so forms()
could list an empty form that contains() never accepts. Empty forms
are no longer listed, at the first suffix, in a chain or with a prefix.
- A circumfix suffix at the start of a suffix chain was only checked
for the outermost rule, so work/A, SFX A able/Bc, SFX B s was
accepted as "workables" without the prefix. A circumfix suffix under
another suffix is no longer a way to a word without its prefix, and
forms() does not chain a form that ends in one.
New tests cover both. Over the whole hunspell-lv lv_LV dictionary
(66,480 stems, 4.5 million forms) every form forms() lists is still
accepted by the checker.
Ported from storytold/wordcraft#5012 (d655b3188) onto craft-proof.
Adapted to flag ids; the form_has_flag circumfix filter already came in with the first port.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
The circumfix filter on the prefix cross-product loop of forms() only
counted work for suffix forms that matched the circumfix class of the
prefix. A dictionary with many circumfix prefixes and thousands of
ordinary cross-product suffixes could scan the incompatible ones
without ever reaching MAX_FORMS_WORK.
The loop now counts each suffix form it looks at, then skips the ones
of the wrong class, so the budget bounds the scan as it did before the
filter was added.
Ported from storytold/wordcraft#5012 (bc947e733) onto craft-proof.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
The first-level suffix loop of forms() skipped suffix rules that need
another affix or are circumfix ones, and rules that would replace a
whole stem without FULLSTRIP. The chain loop did not, so forms() could
list a chained form the checker rejects (work/A, SFX A able/B,
SFX B s/N with NEEDAFFIX N listed workables).
The chain loop now skips an empty base without FULLSTRIP and does not
list a form whose last suffix needs another affix or is a circumfix
one; such a form is still queued, so it can carry further suffixes.
The new test checks that every listed form is accepted for the three
cases.
Ported from storytold/wordcraft#5012 (2f3a83f6e) onto craft-proof.
Adapted to flag ids (cont_holds).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
The affix engine refused any dictionary that uses these three
directives, so a dictionary such as Latvian lv_LV (hunspell-lv,
LGPL-2.1+) never loaded and its language showed "(dictionary
unreadable)".
- FULLSTRIP: an affix may strip, and so replace, a whole stem.
- NEEDAFFIX: a stem carrying the flag is not a word on its own, and a
rule with it among its continuation classes gives forms that need one
more affix (a prefix satisfies it for a suffix and the other way).
- CIRCUMFIX: a prefix or suffix carrying the flag is valid only
together with one that carries it too; plain affixes never pair with
circumfix ones.
forms() follows the same rules, so every form it lists is still
accepted by the checker, and the compact format keeps the three
directives. Tests cover each directive, the forms invariant and the
compact round trip.
Ported from storytold/wordcraft#5012 (989e942bc) onto craft-proof.
Adapted to craft-proof's flag ids (this branch sits on the FLAG
num/long port, storytold/wordcraft#5142): NEEDAFFIX and CIRCUMFIX are
flag ids read in the FLAG mode, continuation-class tests are set
lookups, and NEEDAFFIX joins the lookup exclusions. The ROADMAP.md part
and the Latvian language module (c99142ae9) are WordCraft's, not ported.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
- Lookup cost (P2): form_has_flag tests each distinct continuation set
once per lookup, against a bitmap when many flags are looked for, so a
check no longer costs rules × set size. 21,024 rules with 1,024-flag AF
continuations: 75 s for 40 checks before, well under a second now
(big_continuation_sets_on_many_rules_stay_fast). Distinct flag sets are
capped at 8 Mi flags in all, which bounds a lookup.
- Homonym merge (P2): a homonym that adds no flag doesn't re-sort the
merged set (many_repeated_homonyms_load_fast).
- Malformed .dic flags read as Hunspell 1.7 reads them (black-box, the
hunspell CLI on the same files), keeping the word: under FLAG num an item
that is no number from 0 to 65,535 (empty, x2, -1, 70000) is flag 0, and
000001 is 1; under FLAG long an odd last character is dropped. So
Nepali's निजामती/I15,22 keeps flag 22. Continuation classes stay strict:
Hunspell drops such a rule.
- AF: aliases are read once, in build() and in the final FLAG mode; the
stems using one share its flags instead of copying them. At most 65,536
aliases and 4 MB of alias text.
- to_bytes returns Result: data over from_bytes' 64 MB inflate limit is
refused when written.
- ONLYINCOMPOUND sits after COMPOUNDMIN in the compact format again, so
one-character data is byte-identical. It is also written without compound
rules, so its stems stay compound-only after a round trip.
- Documented: a FLAG line that changes the mode after flags were read is
still refused (Hunspell loads it), and long flags are read as characters
(Hunspell reads bytes). The comment now says three exclusion flags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
From the rebase onto craft-libs#483: the ONLYINCOMPOUND flag is a flag id
read as FLAG long/num/UTF-8 says, so a stem carrying it is a word only
inside a compound in every flag mode (English included). COMPOUNDRULE
patterns with long or numeric flags write each flag in parentheses,
`(aa)*(bb)` / `(1)*(2)`, as Hunspell reads them (checked black-box with the
hunspell CLI); a bare flag in those modes or an unclosed group is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Follow-ups to the storytold/wordcraft#5142 port:
- Flag 0 is a flag under `FLAG num`, as Hunspell 1.7 reads it (black-box
check): the Turkish dictionary's first rule is `SFX 0 N 1`, which the
port refused. Turkish now loads and passes its check words.
- AF aliases hold flags written in the FLAG mode (`AF AaBb`, `AF 12,7`);
tested against Hunspell's answers on the same files. An alias whose
flags don't read leaves its stem out, as a bad .dic field does.
- A homonym past the merged-set cap keeps SUBSTANDARD as it keeps
FORBIDDENWORD and NOSUGGEST (craft-proof has SUBSTANDARD; the PR's base
didn't).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
A homonym that took the merged flag set past MAX_FLAGS_PER_ENTRY was
skipped whole, so if it alone carried FORBIDDENWORD or NOSUGGEST the word
became correct or suggestible.
Merged sets now have their own bound, MAX_FLAGS_PER_STEM (8 x the entry
cap), which the compact reader also accepts for stem lines, so to_bytes ->
from_bytes reloads every set build() makes. A homonym past that bound
drops its other flags but still adds its exclusion flags (evicting a
non-exclusion flag if the set is full), and a .dic entry over the entry
cap is still no word but its exclusions apply to its homonyms.
Ported from storytold/wordcraft#5142 (b9777fc92) onto craft-proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Review follow-ups:
- Two .dic entries for the same word could each stay within
MAX_FLAGS_PER_ENTRY while their merged set went past it; the compact
data then held a set its reader refuses, and the word was lost on
reload. An entry that would take the merged set past the cap is now
skipped, as an oversized entry is.
- In one-character mode a FORBIDDENWORD or NOSUGGEST flag that no stem
or rule uses was written to the compact data, which the old format
left out. It is left out again, so those dictionaries' bytes don't
change.
Ported from storytold/wordcraft#5142 (f10bb61ab) onto craft-proof.
Conflict resolved: the used-flag filter covers SUBSTANDARD and KEEPCASE too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
The affix engine read one-character flags only, at most 128 of them, as
bits of a u128. It now reads `FLAG num` (comma-separated numbers 1-65535,
leading digits as in Hunspell) and `FLAG long` (two characters), with up
to 65,536 distinct flags (storytold/wordcraft#4966):
- One reader (`FlagMode`) parses and writes every flag field: .dic flags,
PFX/SFX flags and continuation classes, FORBIDDENWORD and NOSUGGEST.
- Each flag gets a dense u16 id; a stem's flags and a rule's continuation
classes are sorted id sets interned in one flat table (set 0 is empty).
- Hostile input: at most 1,024 flags per entry (Belarusian uses 264), a
bad .aff flag is an error, a .dic entry whose flags don't read is
skipped, and a FLAG line after flags were read is refused.
- Lookups skip the continuation walk for groups and flags no continuation
class can reach, so checks are as fast or faster with the Polish,
Portuguese, Czech, Italian and Russian dictionaries (same answers).
- The compact format writes a `FLAG num`/`FLAG long` line only for those
modes, so one-character data is byte-for-byte unchanged and old data
still loads.
LibreOffice's Serbian (Cyrillic and Latin), Belarusian, Sinhala, Kannada
and Nepali dictionaries now load; Danish, Galician, Icelandic and Korean
still stop at COMPOUNDMIN, NEEDAFFIX, FULLSTRIP and ICONV.
Ported from storytold/wordcraft#5142 (f5778bb76) onto craft-proof.
craft-proof already had storytold/wordcraft#735 (AF aliases, SUBSTANDARD,
KEEPCASE, ICONV, IGNORE, 256 one-character flags as a bitmask), which
this commit predates; conflicts resolved by carrying those through the
flag ids: AF aliases resolve to flag text read in the FLAG mode,
SUBSTANDARD and KEEPCASE are flag ids like FORBIDDENWORD and NOSUGGEST,
and lookups exclude up to four flags (Excluded). The FLAG long test
lines in the language modules and slot.rs now use an unknown mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
craft-libs#483 re-review (P2): ise_spelling turned any z between i/y and a
vowel into s, so en-GB (and the other British Englishes) accepted
misspellings whose -s- form is a word: wize, rize, noize, dizease, advize,
surprize, exercize… (19 reproduced; Hunspell en-GB rejects them all). The
-ize form now counts only when the word itself is in the en-US or en-CA
SCOWL dictionary and its -ise form in en-GB's (organize, realization,
colourize). English with no dialect no longer needs the swap (it reads all
three). Test: british_ize_needs_a_real_ize_word (the 19, plus colorize, wrong
in en-GB and en-AU; organize, realization, colourize, organizing, realizes
right).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
craft-libs#483 review (P2): English spelling checked the union of the three
SCOWL dictionaries, so dialects crossed (en-US took colourblind, greyscale;
en-GB took colorblind, esophagi). Each English now checks its own dictionary
only. British English also takes the -ize spellings of its -ise words
(SCOWL's en-GB is -ise only; Word accepts both), and en-variants.txt widens
each dialect by its pairs; judgment/judgement come from craft-i18n#114, which
CI now pins. English with no dialect (is_correct) still takes a word any
English knows. Test: scowl_dialects_dont_cross samples the stems one
dialect has and another lacks, for every pair.
P3s:
- English suggestions (a Tuning of their own): a missing space only between
two lower-case words of three letters or more, ranked after every one-word
candidate (publically: publicly first; no "Thea tr", "Che Que"), and no
word the dictionary only capitalizes (Col, Wis) for a lower-case word.
- A FORBIDDENWORD whole word is never rebuilt from compound parts.
- to_bytes writes ONLYINCOMPOUND without compound rules too (round-trip test).
- Failed lookups: the compound part table is filled lazily, and a word that
doesn't start like a compound part (SCOWL: a digit) skips compounding;
20,000 failed long words take 83 ms, as without compounding (1.09 s before).
- README: en-CA is not a British-spelling English.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
craft-i18n's SCOWL rel-2026.02.25 dictionaries (en-us, en-gb -ise, en-ca)
are embedded by build.rs and read per English: en-US, the British-spelling
Englishes (en-AU, en-NZ, en-IE...) through en-GB, en-CA. Suggestions come from
the run's own dictionary; a word is right when any of the three (or
en-extra.txt) knows it, unless en-variants.txt marks it as the other dialect's.
en-variants.txt stays: SCOWL's size-60 lists keep one spelling per dialect and
Word accepts more (organize and organise in en-GB, judgement in en-US).
English lookup order: dictionaries folder (the run's own English only),
[installed English pack, craft-libs#485], embedded SCOWL, system Hunspell
(own English only; not searched while SCOWL is embedded), the built-in Moby
list. The embedded Moby list is no
longer a spelling source (the built-in list is the same list); it stays for
hyphenation. The dialect policy applies to every source.
The affix engine gains COMPOUNDRULE (one-character flags, * and ?),
ONLYINCOMPOUND and COMPOUNDMIN, which SCOWL uses for ordinals (21st, 112th).
Hostile patterns are capped (64 rules, 32 tokens, 48-byte words) and checked
by a memoised walk. load_english now reads them instead of ignoring them (and
keeps ONLYINCOMPOUND stems as compound parts); only flag-based compounding and
OCONV are still ignored for English.
scowl::notice() exposes the SCOWL/Ispell/Titze notice for About. Embedded
Language data grows by 1,542,396 bytes. CI pins craft-i18n#109's merge commit efb1127.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Re-review of craft-libs#482:
- en-GB and en-CA never fall back to an en_US Hunspell dictionary, which marked colour,
organise, centre… (CI's runner has one); without their own they use the built-in Moby list,
which has both spellings. en-US looks for en_US/en-US only.
- hunspell::candidates goes name by name across every folder, so a system en_GB beats an
en_US in the dictionaries folder.
- Built-in suggestions rank swaps, then common words, neighbouring keys, the same first letter;
no one-letter suggestions but a and i (hte -> the, recieve -> receive, wprd -> word).
- The generator leaves out Moby entries that are far more often typos: teh, abd, nad, ur, u, r
(wont and cant stay: words). en-words.dic regenerated (343,988 bytes).
- A load that started before a reload no longer notes its load error; reload clears the
English load errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Review of craft-libs#482:
- craft_proof_data is set only when every Language data file is embedded, and the new
craft_proof_no_data only when none is, so a partial folder (en-us but no pl) runs neither
the pattern tests nor the no-data tests.
- A CRAFT_I18N_DIR without language-data/ is watched (rerun-if-changed), so the build
notices when the checkout gains it.
- A CRAFT_I18N_DIR that isn't valid UTF-8 is reported and embeds nothing, instead of a lossy
path in include_str!.
- proof.yml checks out craft-i18n at 062a6375c322c88b96691a17f7b5fed71c51f7a8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Review of craft-libs#482:
- The built-in fallback is now the full public-domain Moby word list (data/en-words.dic,
words only, 343,992 bytes, from craft-i18n's en-us.dic by examples/en_words.rs) plus
WordCraft's own en-extra.txt (MIT OR Apache-2.0). An unknown word is misspelled; the
near-miss scan (which flagged fiber, theater, organize… and took 14 s on a 2,000-word
token) is gone. Suggestions are the words one edit away, looked up, for words of up to 32
letters. spelling_scales_over_one_long_token runs without the data, with a time bound.
- English Hunspell dictionaries ignore compounding (COMPOUNDMIN, COMPOUNDRULE…; their
ONLYINCOMPOUND stems are left out) and OCONV instead of refusing the file, and a file that
can't be read is skipped for the next name or folder.
- A load that started before english::reload() isn't kept (reloads counter, as slot.rs);
Englishes that use the same .dic share one copy. en-GB/en-CA falling back to en_US is
documented.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Ported from storytold/craft-libs#31 (9780561, "craft-proof: search the app's
own dictionaries folder on macOS and Linux too"), keeping WordCraft's API
(`hunspell::search_dirs`, `find`, `set_dictionary_dir`) instead of
`Search::for_app`.
The per-app folder was searched only on Windows. macOS now searches
~/Library/Application Support/WordCraft/dictionaries and Linux and the BSDs
$XDG_DATA_HOME/wordcraft/dictionaries (default ~/.local/share; lower-case, as
XDG folders and WordCraft's ~/.config/wordcraft are), after the setting and
WORDCRAFT_DICTIONARIES and before the shared system locations, so an app
started from Finder, the Dock or a launcher finds a dropped-in dictionary
without a setting (storytold/wordcraft#1063). Windows is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Merges the crates/proof history from storytold/wordcraft a88e00525..9c72ddf56
(storytold/wordcraft#5000, storytold/wordcraft#4235: 21 commits and 5 merges),
split with the same git filter-repo run as the first import, so the shared
history has the same commit ids and every commit keeps its author.
Conflicts in src/lib.rs, resolved with the CP1.2 changes kept: the embedded
`data` module beside the new `affix_suggest`; a dictionaries-folder change
reloads every language slot and English; English load errors still come from
the English source; the dictionaries-folder test helpers stay as CP1.2 split
them, with DICTIONARY_TESTS documented for the user-dictionary tests too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Tests and notes for rules that storytold/wordcraft#4129, storytold/wordcraft#4113, storytold/wordcraft#4109 and storytold/wordcraft#4101 added and
left uncovered:
- A border line at the top of a story with formatting left out of the
Track Changes markup is not a formatting change, and Reject All keeps
it.
- The author's own earlier formatting change on that paragraph stays the
one change.
- Reject All keeps the table of a "+---+" line.
- A border line that a content control's lock stops Backspace from
deleting stays as it is, and Enter is refused, untracked too.
- With tab indents on, Tab in a table cell moves to the next cell; with
them off, Backspace before an indented paragraph joins it.
- AutoCorrect and the spelling check's web and e-mail addresses after a
narrow no-break space and a figure space.
docs/typing-parity.md describes each, and lists a no-break space typed
after a word, which does not finish the word for AutoCorrect, as a
known gap.
- No embedded Moby list: English (en-US, en-GB, en-CA) uses an English
Hunspell .aff/.dic from the dictionaries folder or the system (the run's
English first, then another English), else data/en-core.txt.
- en-core.txt: 11,367 public-domain Moby Hyphenator II words (Grady Ward),
ranked by lower-case frequency in 50 public-domain Gutenberg books
(examples/en_core.rs, book ids recorded). 94 KB. Marks only near misses
of common words (swapped letters; one edit for lower-case words of 4+).
- Unreadable English dictionary (encoding, not UTF-8, no words, size):
error via dictionary_load_error, falls through to the core list; a bad
embedded list is reported the same way. Never a panic.
- Non-English text is never checked against English.
- New: english_source(lang) -> Option<EnglishSource>.
- Embedded-data path unchanged; tests with data unchanged.
- Tests: core list flags/accepts, folder Hunspell preferred over core list,
unreadable dictionaries reported, no_data English now marks misspellings;
two data-ignored tests now run without data. README, ATTRIBUTION, NOTICE.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
`Kmm` with `km/K` suggested `Km`, which the checker flags; `Km` itself got no
suggestion. A suggestion whose dictionary form is only correct through a
KEEPCASE stem now keeps that case (`km`). Other stems are re-cased as before
(`MIXCASX` → `MIXCASE`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
- Package renamed wordcraft-proof -> craft-proof; [workspace.dependencies]
entry; public API unchanged.
- build.rs embeds craft-i18n language-data/ (en-us, de, pl, pt, it, ru)
instead of include_str!/include_bytes! of WordCraft's assets/: warns when
CRAFT_I18N_DIR is unset, errors under CRAFT_I18N_REQUIRED=1.
- No data: English spelling marks and suggests nothing, hyphenation that
needs data is off, grammar and rule-based hyphenation work. no_data.rs runs
every public entry point without data; data-pinning tests are ignored then
(cfg craft_proof_data).
- es timing bound 75 -> 150 ms: craft-libs tests run at opt-level 0.
- proof.yml (fmt, clippy -D warnings, tests with and without data, release
guard, wasm32), README, CODEOWNERS for crates/proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Merges the history of storytold/wordcraft crates/proof (84 commits, up to
wordcraft@a88e00525), split with git filter-repo so blame survives. Issue
references in the imported messages are rewritten to storytold/wordcraft#N.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
* krilla: CMYK, gray and spot colors keep their exact inks (patch 17)
Gray, CMYK, Separation and DeviceN colors stored each component as a
u8, so every ink was written as a multiple of 1/255: C75 became
0.7490196 and C4 M4 Y4 K4 became 0.039215688.
- cmyk, luma, separation and device_n Color::new_f32 take components
from 0 to 1 and write them as given. The u8 constructors are
unchanged.
- Gradient stops rewritten into one DeviceN, Separation or CMYK space
keep their components.
- Mesh gradients write corner colors at 16 bits a component.
* Tests: the 75% K mesh corner comment gives the asserted 0.749996
Dict validates a dictionary by skipping its values, and the skip accepts
some values that the parser cannot read: a name with a broken # escape
(/#zz), a number such as 12-.5, and a dictionary followed by stream with
no endstream. Dict::entries and Dict's Debug output unwrapped the result
of parsing every value, so either one panicked on such a dictionary.
Port LaurenzV/hayro#1501 to the vendored copy: entries skips an entry
whose value does not parse, and Debug prints the entries. ArrayIter::next
already ends the iteration on such an item here. Add LaurenzV/hayro#1501's
two tests, update the local hashes of dict.rs and array.rs and the repair
list in SOURCE.json, and note the change in vendor/README.craft-reader.md.
The first time a process opens a PrintTicket provider for a queue whose
provider is PrintConfig.dll, winspool.drv creates a process-wide cache
without a lock. When two threads make those first calls together,
winspool can keep a pointer to a cache it has already freed, and a later
PTOpenProvider call crashes the process with an access violation in
ntdll. Listing printers from several threads at once reaches it through
two_sided.
Hold one process-wide lock around every call that reaches the provider:
PrintCapabilities::fetch in two_sided and driver_ticket, the
PrintTicketBuilder in driver_ticket, fetch_xml_for_ticket in
imageable_margin, and the start of print_with_progress, where winprint
converts the ticket to a DEVMODE. The job releases the lock at its first
progress report, before it sends progress to the submitting thread. A
lock poisoned by a panic is still taken.
craft_keymap::Key gains IntlBackslash, the key between left Shift and Z
on ISO keyboards (`<` and `>` on Turkish Q). Key::from_egui and to_egui
map it to egui's IntlBackslash, so the shortcut recorder records it and
the dispatcher runs its bindings. Its shortcut text is `IntlBackslash`
(`Cmd+IntlBackslash`, `Shift+IntlBackslash`), read without regard to
case; `<` and `>` still parse as no key.
The key counts as a symbol position: Shift stays in the chord, and once
the layout has typed `<` there, display_for shows `Ctrl+<`.
MAX_CHORD_TEXT goes from 64 to 72 bytes so that the longest canonical
trigger, `Cmd+Ctrl+Alt+Shift+IntlBackslash` pressed twice, reads back.
get_api shared the 4 MiB limit for small answers. A launcher version list of
100 releases can pass that: SolidCraft's was 6.5 MB (craft-launcher#1021).
Other small requests keep 4 MiB.