Her apostrophe was cutting French words in half
Typography.ts rewrites every ' typed in the editor into a curly ’, but both word regexes only counted the straight one. So "aujourd’hui" reached the dictionary as "aujourd" + "hui", neither of them a French word, and one of the commonest words in the language came back wearing two red underlines. Same for quelqu’un, presqu’île, prud’homme. l’arbre only survived by accident, because "l" happens to be a bare entry. withElision, written for exactly this, could only ever fire on pasted text. Both marks are word characters now, and combine() straightens on lookup — the one place every lookup passes through — since the shipped word lists spell theirs straight. Suggestions come back wearing whichever mark she actually used, so accepting a pill never swaps her apostrophe. œ was untokenizable too: U+0152/U+0153 sit outside the Latin-1 ranges, so "cœur" split into "c" + "ur" and the orphan was long enough to underline. 586 œ forms ship in fr.dic.gz and not one of them was reachable. In the dictionary builder, the two cross-product paths added their forms without the NEEDAFFIX check the single-affix paths apply, so a doubly-affixed form that is still "not a word on its own" was accepted anyway — the exact class of error the FLAG-aware rewrite exists to close. PFX and SFX are also separate flag namespaces, and one shared `cross` dict let the second block overwrite the first. Odd-length long-flag strings now stop the build instead of dropping a character and expanding through the wrong paradigm. The pt-PT and Québécois greps were case-sensitive against sentence-cased copy, which let a leading "Actualmente…" through the guard added to catch it. Note: this changes what the expander produces, but fr.dic.gz and pt-PT.dic.gz are vendored and were built with the old behaviour. Both want regenerating on a box that can fetch the upstream .deb, and BUILD_PLAN Phase 24's "pt-PT rebuild is byte-identical" claim re-checked — if those bytes move, the NEEDAFFIX gap was live in the Portuguese list too. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -145,7 +145,12 @@ class Aff:
|
||||
def __init__(self):
|
||||
self.pfx = {} # flag -> [(strip, append, condition, continuation)]
|
||||
self.sfx = {}
|
||||
self.cross = {} # flag -> bool
|
||||
# Cross-product, per flag — and kept per table, because PFX and SFX are
|
||||
# separate flag namespaces in hunspell: the same flag may name a prefix
|
||||
# table and a suffix table, with different cross-product settings. One
|
||||
# shared dict let the second block silently overwrite the first.
|
||||
self.cross_pfx = {} # flag -> bool
|
||||
self.cross_sfx = {}
|
||||
self.flag_kind = "char"
|
||||
self.needaffix = None
|
||||
self.circumfix = None
|
||||
@@ -159,7 +164,13 @@ def parse_flags(raw, kind):
|
||||
if not raw:
|
||||
return set()
|
||||
if kind == "long":
|
||||
return {raw[i:i + 2] for i in range(0, len(raw) - 1, 2)}
|
||||
# Two characters per flag, exactly. An odd length is a malformed flag
|
||||
# string, and silently dropping the trailing character would quietly
|
||||
# expand an entry through the wrong paradigm — the failure mode this
|
||||
# whole FLAG-aware rewrite exists to avoid.
|
||||
if len(raw) % 2:
|
||||
raise SystemExit(f"odd-length long flag string {raw!r}")
|
||||
return {raw[i:i + 2] for i in range(0, len(raw), 2)}
|
||||
if kind == "num":
|
||||
return {f for f in raw.split(",") if f}
|
||||
return set(raw)
|
||||
@@ -204,7 +215,8 @@ def parse_aff(path):
|
||||
if parts and parts[0] in ("PFX", "SFX"):
|
||||
kind, flag, cross_flag, count = parts[0], parts[1], parts[2], int(parts[3])
|
||||
table = aff.pfx if kind == "PFX" else aff.sfx
|
||||
aff.cross[flag] = cross_flag == "Y"
|
||||
cross = aff.cross_pfx if kind == "PFX" else aff.cross_sfx
|
||||
cross[flag] = cross_flag == "Y"
|
||||
rules = table.setdefault(flag, [])
|
||||
for j in range(1, count + 1):
|
||||
p = lines[i + j].split()
|
||||
@@ -298,24 +310,30 @@ def expand_entry(word, flags, aff, out):
|
||||
# Prefix then suffix. The suffix flag may come from the stem or from the
|
||||
# prefix's own continuation (`PFX Um 0 0/S.`), and the suffix condition is
|
||||
# matched against the whole prefixed word, which is what hunspell does.
|
||||
# NEEDAFFIX is checked here too, exactly as on the single-affix paths above:
|
||||
# a doubly-affixed form whose last continuation still carries the flag is
|
||||
# "not a word on its own", and without compounding there is no third affix
|
||||
# left to make it one.
|
||||
for form, pf, pcont in prefixed:
|
||||
if not aff.cross.get(pf):
|
||||
if not aff.cross_pfx.get(pf):
|
||||
continue
|
||||
for f in flags | pcont:
|
||||
if f in aff.sfx and aff.cross.get(f):
|
||||
for full, _ in apply_suffix(form, aff.sfx[f]):
|
||||
out.add(full)
|
||||
if f in aff.sfx and aff.cross_sfx.get(f):
|
||||
for full, fcont in apply_suffix(form, aff.sfx[f]):
|
||||
if is_word(fcont):
|
||||
out.add(full)
|
||||
|
||||
# Suffix then prefix — the same pair reached from the other side, which is
|
||||
# how the elision prefixes arrive in French. Only the flags the suffix hands
|
||||
# forward are new here; the stem's own were covered above.
|
||||
for form, sf, scont in suffixed:
|
||||
if not aff.cross.get(sf):
|
||||
if not aff.cross_sfx.get(sf):
|
||||
continue
|
||||
for f in scont:
|
||||
if f in aff.pfx and aff.cross.get(f):
|
||||
for full, _ in apply_prefix(form, aff.pfx[f]):
|
||||
out.add(full)
|
||||
if f in aff.pfx and aff.cross_pfx.get(f):
|
||||
for full, fcont in apply_prefix(form, aff.pfx[f]):
|
||||
if is_word(fcont):
|
||||
out.add(full)
|
||||
|
||||
|
||||
def expand(aff_path, dic_path):
|
||||
|
||||
Reference in New Issue
Block a user