Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1ab5cef7 | ||
|
|
071ea7b835 |
@@ -10,6 +10,10 @@ web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
*.log
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local env & data
|
||||
.env
|
||||
*.db
|
||||
|
||||
+25
-9
@@ -309,18 +309,33 @@ Raised by the user, not by the plan: *"I see no way to change my language in the
|
||||
**Deployed 2026-07-27** (`1f4ca47`), and it carried Phase 22 with it — the two could not be separated in the tree, so the user chose to ship both. Pre-deploy snapshot `data/backups/petal-pre-phase22-20260727T220410Z.db` (VACUUM INTO against the live app). On the box: migrations 0012 and 0013 applied, `source` backfilled **llm=100 / local=3** — the three are her pre-existing `mechanics` rows, claimed by type exactly as the migration intended. Her writing came through untouched: 9 documents, 33 versions, 103 suggestions, `integrity_check` ok. All four containers healthy, read-aloud still resolving `en/pt/zh`, dictionary still open on all five languages. `PATCH /api/me` answers **401 without a session** rather than 405, which is the only half of it a signed-out probe can prove — the route is mounted and behind auth.
|
||||
- **All three accounts are still on `zh`, deliberately.** Flipping her pair is hers to do now that the button exists, and doing it for her from a shell is precisely the change this phase was built to stop needing.
|
||||
|
||||
### Phase 24 (planned) — the fr and es pairs
|
||||
Scope agreed with the user 2026-07-27: *"switcher for Chinese and Portuguese now, plan support for others in a later session or two."* Phase 21 is the groove; the work per language is the same five items, and the order below is the order in which each one stops being a blocker for the next.
|
||||
1. **The langpack** (~450 lines, `web/src/i18n/packs/{fr,es}.ts`). TypeScript names every string a new pack still owes, so this is mechanical to *start* and slow to *finish* — the companion lines, the bedtime proverbs and the false-friend list are written for the pair, not translated from zh. es and fr both have real en-collisions to exploit (*actuellement*/*actually*, *librería*/*library*), so both want the `alsoIn` and false-friend blocks pt-PT proved. Add the code to `auth.shippedPairs` **in the same commit** — the picker and the server's allowlist are two halves of one fact.
|
||||
2. **A native-speaker review.** Standing at ⚠️ for pt-PT since Phase 21 and inherited here; expect a speaker to change the register before the vocabulary.
|
||||
3. **Hunspell dictionaries.** `scripts/build_ptpt_dictionary.py` generalizes — the eager-affix-expansion problem is French's and Spanish's too, and both are Latin-script so `extendedAlphabet` already covers them. Watch the same trap that caught pt: check what the *source* actually is before vendoring it (fr has `hunspell-fr-classique` vs `-moderne` vs `-toutesvariantes`; es is packaged per country).
|
||||
4. **Piper voices.** Phase 21 made this configuration rather than code: a compose service and two `.env` lines per language (`TTS_ENDPOINT_FR`/`TTS_VOICE_FR`). fr and es both have several European voices in Piper's catalogue, and unlike pt-PT the download path is plain ASCII — so this is the cheapest item on the list.
|
||||
5. **Lexicon coverage.** `dict.db` has held all five languages since Phase 20, so both directions should already answer; measure gloss coverage the way pt-PT's 62% was measured before assuming it.
|
||||
Not blockers, and cheap because Phase 19 did them: `internal/llm/lang.go` already carries fr and es, and `grammarLite`'s L1 rules already gate *ter 30 anos* / "I am agree" / "since three years" to pt+fr+es.
|
||||
### Phase 24 — the fr pair ✅ (2026-07-27, code half) — and what the plan got wrong about it
|
||||
Scope agreed with the user 2026-07-27: *"switcher for Chinese and Portuguese now, plan support for others in a later session or two."* Then, this session: **French end to end, code only, deploy its own step** — es follows as a repeat of a proven groove rather than two half-finished pairs. Phase 21 was supposed to be the groove and mostly was; the exception is item 3, which the plan had recorded as solved and was not.
|
||||
1. [x] **The langpack** (`web/src/i18n/packs/fr.ts`, 470 lines). Metropolitan French, tutoiement, *se connecter* rather than *login* — and the regional decision lives **entirely here**, unlike pt: Debian's `fr_FR`, `fr_CA`, `fr_BE`, `fr_CH`, `fr_LU` and `fr_MC` are all symlinks to one word list, so there is no dictionary to get wrong and nothing but the copy to get right. A vitest greps the built pack for *courriel*, *clavarder*, *magasiner* and *fin de semaine*, exactly as the pt-PT one greps for Brazilian forms — the error nobody reviewing the diff can see. The pack punctuates the way French does (« guillemets », a space before ! ? : ;), which is *also* the habit `prose.spaceBeforePunct` warns her about in her English: the copy demonstrates the rule its own prose note tells her not to carry across. An ordinary space, not U+202F — a narrow no-break space is invisible in a diff and the next pack author would strip it by accident.
|
||||
2. [~] **Reviewed by a quorum of models, not by a native speaker** (2026-07-27, user: "perhaps for now, we could leverage multiple LLMs to act as reviewers… accept the responses that have the most alignment amongst them" — explicitly an interim measure). Four models read each pack independently as native speakers, blind to one another, returning verbatim substrings so agreement could be counted mechanically rather than judged. **Threshold: a finding is applied only if ≥2 of 4 reached it on their own.** Eight reviews, 32 findings, 12 above threshold, 10 applied.
|
||||
- **fr, applied**: `Fatiguée, on écrit mal` (**4/4**), `Clique droit sur un mot anglais` → *Fais un clic droit* (3/4), `je me suis emmêlée` (3/4), `touche pour changer` → *appuie* (2/4), `laisse une espace` → *un espace* (2/4).
|
||||
- **pt-PT, applied**: `adjectivos` → *adjetivos* (3/4), `actualmente` → *atualmente* (3/4), `decepção` → *deceção* (2/4), `Cão abanão` → *Cão abana-rabo* (2/4), `Ouves? Pois não…` → *Pois não ouves…* (2/4).
|
||||
- **Where the reviewers agreed a line was wrong but not on the fix**, the wording is mine and the reasoning is written down rather than averaged: `Fatiguée, on écrit mal` split 2–2 between keeping *on* and switching to *tu*, and *both* camps' stated objection (feminine agreement with impersonal *on*) survives the *on* wording — so **Quand tu es fatiguée, tu écris mal** is the only candidate that answers every reviewer, and it matches the pack's own tutoiement. `je me suis emmêlée` drew three different fixes; *emmêlé les pinceaux* is the actual idiom and makes the participle invariable, which also settles the fourth reviewer's point that the companion is a *chat* and therefore masculine.
|
||||
- **The finding that justifies the exercise**: pt-PT was carrying **pre-Acordo spellings** — *adjectivos*, *actualmente* — in direct contradiction of its own header, plus Brazilian *decepção*. Phase 21's vitest greps the pack for Brazilian *vocabulary* and never checked the pack against its own stated *spelling policy*, so this had been shipped and reviewed and was still invisible. The grep now covers nine pre-Acordo forms, and was confirmed to fail on the old text before being kept.
|
||||
- **Below threshold, deliberately not applied** (1/4 each): *very* also modifies adverbs, so `veryBeforeVerb` is incomplete rather than false — and the rule that renders it only runs for the zh pair anyway; `aide à lire`; `et toi aussi tu devrais`; `Cansada não se escreve bem`; `Já vais em`; `está toda a gente a dormir`; the `longSentence` infinitive; `breaks[1]`.
|
||||
- ⚠️ **Still not a native speaker.** A quorum of models agreeing is agreement, not authority: it caught a *clique droit* that is not French and an adjective disagreeing with *on*; it cannot catch a line that is correct and lifeless. The ⚠️ at the top of both packs now says which review happened rather than none.
|
||||
3. [x] **Hunspell dictionary — and "the pt-PT script generalizes" was wrong.** It handled single-character flags and plain PFX/SFX and *stopped* on anything else, which was the right call and not a generalization: `fr.aff` uses four of the things it stopped on, and every one changes which words are accepted. **`FLAG long`** — French flags are two characters (`S.`, `L'`, `Um`), so `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm; this is the one that fails silently. **Continuation flags** — French really does affix an affixed form (`PFX Um 0 0/S.`), which pt-PT dropped after asserting it was safe to. **`NEEDAFFIX`** on 68,075 of 84,140 stems, the bare form arriving through a zero-append rule. **`FULLSTRIP`**. `CIRCUMFIX` and `FORBIDDENWORD` are declared-but-unused and the script now *asserts* that rather than assuming it. Renamed `scripts/build_hunspell_dictionary.py` with a per-language profile; **the pt-PT rebuild is byte-identical to the shipped asset**, which is what says the generalization did not change the pair that already worked.
|
||||
- **Which of three, not which of six.** fr is packaged by how it treats the 1990 reform: `-classical`, `-revised`, `-comprehensive`. Petal ships **comprehensive**, because Petal never corrects her French — the only thing this dictionary can do is underline something, and *coût* and *cout* are both correct French taught in different decades. The MUST_ACCEPT list *proves* which package was used: classical rejects `cout`, revised rejects `coût`, only comprehensive accepts both.
|
||||
- **Elision is the size decision, and it moved out of the dictionary.** Both halves were built and measured: keeping the elided forms is **3,159,832 forms / 8.25 MB gzipped**; dropping them is **473,326 / 1.19 MB**. They are not new words — thirteen clitics glued to words already in the list — but the tokenizer keeps internal apostrophes, so `l'arbre` really does arrive whole and really would have been underlined. `withElision` splits at a *known* clitic and checks the remainder: `l'arbre` costs one extra hash probe instead of seven megabytes, `zzz'arbre` is still flagged because zzz is not a word French elides, and `l'zzzz` is still flagged because the remainder must itself be a word. Stems carrying their own apostrophe (`aujourd'hui`, `quelqu'un`, `presqu'île`) are kept verbatim and match directly; `entr'aide` is absent for the same reason Dicollecte omits it.
|
||||
- Loaded in a real nspell: **369 ms, 74 MB** for 473,326 forms — cheaper than pt-PT's 842 ms / 139 MB, on a bigger language. Suggestions do the thing an ESL writer needs most: `ecrire` → *écrire*, `francais` → *français*.
|
||||
4. [x] **Piper voice** — `piper-fr`, a fourth sidecar off the same image, plus `TTS_ENDPOINT_FR` and `TTS_VOICE_FR`. No Go at all, which is Phase 21's discovery holding: a language is configuration now. The exact opposite of pt's trap — every `fr_*` voice in the catalogue is `fr_FR`, so there is no wrong country to default to, and `fr_FR-siwis-medium` is chosen to match the register of the other three rather than to avoid anything. ASCII, so the percent-encoded download fallback `tugão` needed never fires.
|
||||
5. [x] **Lexicon coverage** — already measured, and better than the pair that shipped: the Phase 20 rebuild put fr at **63.1%** of the 2,000 commonest English words against pt-PT's 62.1%. Both directions answer with no code change; `lexicon.Set.For` routes every non-Chinese pair to DreamDict already.
|
||||
- Free, because Phase 19 and 22 did them: `internal/llm/lang.go` already carries fr, `grammarLite`'s L1 rules already gate *ter 30 anos* / "I am agree" / "since three years" to pt+fr+es, and the sidebar picker derives itself from the shipped packs — so the switch offering **Français** is not a line of new UI.
|
||||
- Tests: `i18n.test.ts` (the Québécois grep; French spacing and guillemets kept in the copy; agreement in the interpolated lines — *1 fleur* / *3 fleurs*, *1 chose retenue* / *5 choses retenues*; the fr false friends *attend* and *pass*, which pt-PT has no use for). `spellchecker.test.ts` gains seven elision cases including both directions of the flag-it/don't rule. `pairlang_test.go` now round-trips **every** shipped pair rather than the first one — the Go allowlist and the frontend's PACKS are two copies of one fact — and its unshipped examples moved to `es`/`fr-CA`. `config_test.go` discovers a fourth voice.
|
||||
- Verified: go build/vet, `go test ./...` clean, tsc, vite build, vitest 190/190 (33 in the i18n suite after the review pass). **Not seen in a browser** — no Chrome extension on this laptop; the 1.19 MB dictionary inflating in a real tab and the picker's third entry in a real mobile drawer are what unit tests cannot cover.
|
||||
- **Not deployed.** No migration, so it is a rebuild whenever the user wants it; the Piper sidecar wants `docker compose up -d piper-fr` and a voice download on the box.
|
||||
|
||||
### Phase 25 (planned) — the es pair
|
||||
Everything above, minus the surprises: item 3's expander now handles what Spanish's `es_ES.aff` is likely to need (single-char flags, no compounding), so the work is the langpack, a native review, `hunspell-es` (packaged per country — check what `es_ES` actually is before vendoring), a `piper-es` service with `es_ES-davefx-medium`, and nothing at all for the lexicon: es is the *best*-covered pair in `dict.db` at 68.6%.
|
||||
|
||||
### Later / explicitly not now
|
||||
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model
|
||||
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26** (DreamDict added Spanish). Now a normal follow-on pair after pt-PT, alongside fr — see Phases 20/21.
|
||||
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26** (DreamDict added Spanish). Now a normal follow-on pair after pt-PT and fr — see Phase 25.
|
||||
- Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point
|
||||
- Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
|
||||
|
||||
@@ -332,6 +347,7 @@ Not blockers, and cheap because Phase 19 did them: `internal/llm/lang.go` alread
|
||||
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||
|
||||
## Session log
|
||||
- 2026-07-27: **Phase 24 — the fr pair, and a "generalizes" that did not** (user: "resume the build plan"; scope chosen with the user: French end to end, code only, deploy its own step). The plan's five items were meant to be mechanical, and four of them were — the Piper voice is a compose service and two env lines because Phase 21 made a language configuration; the lexicon needed nothing at all, fr having been measured at 63.1% during Phase 20's rebuild, better than the pair that already shipped; the sidebar picker grew a third entry without a line of UI because it derives itself from the shipped packs. **Item 3 was the one that had been recorded as done and wasn't.** `build_ptpt_dictionary.py` was said to generalize; it handled single-character flags and plain PFX/SFX and stopped on everything else, and `fr.aff` uses four of the things it stopped on. `FLAG long` is the dangerous one: French flags are two characters, so the pt-PT reader's `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm without erroring. Plus continuation flags (French really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and FULLSTRIP. The rewritten `build_hunspell_dictionary.py` carries a per-language profile and asserts that CIRCUMFIX and FORBIDDENWORD are still unused — and **rebuilds pt-PT byte-identical to the shipped asset**, which is the only thing that makes "generalized" a claim rather than a hope. **The second decision was elision, and it was made by measuring both halves**: keeping `l'arbre` and its thirty-three siblings costs 3,159,832 forms and 8.25 MB gzipped; dropping them costs 473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal apostrophes, so they really would have been underlined — so they moved out of the dictionary and into `withElision`, which splits at a known clitic and still requires the remainder to be a word (`l'zzzz` stays flagged). Real nspell: 369 ms and 74 MB for the larger language, against pt-PT's 842 ms and 139 MB. **Where the regional trap lives is the mirror image of Portuguese's**: every `fr_*` Piper voice is fr_FR and every Debian fr dictionary is one shared word list, so nothing can be quietly wrong about the country — the whole decision is in the copy, which is why the pack is greped for *courriel* and *magasiner* the way pt-PT is greped for *arquivo*. What French does have instead is the 1990 reform, packaged three ways; Petal ships comprehensive, because Petal never corrects her French and *coût* and *cout* are both correct. go build/vet/test, tsc, vite, vitest 190/190. **Two things owed and both said plainly**: no native speaker has read the pack (SUGGESTIONS §3's bar, unmet for pt-PT too), and nothing here has been seen in a browser. **Then, same session, an interim answer to the first of those** (user: "perhaps for now, we could leverage multiple LLMs to act as reviewers?"): four models reviewed each Latin pack independently, and only findings ≥2 of them reached on their own were applied — five per pack. It earned its keep on the pack that was *already shipped*: pt-PT had **pre-Acordo spellings in a file whose own header commits to post-Acordo**, because the Phase 21 greps checked for Brazilian vocabulary and never checked the pack against its own spelling policy. That grep now exists and was confirmed to fail on the old text. Where reviewers agreed a line was wrong but split on the fix, the wording is mine and the reasoning is in the phase entry rather than averaged away. Still not a native speaker, and both packs now say so precisely.
|
||||
- 2026-07-27: **Phase 22 finished — the build plan's last four items, and the LLM stops holding anything hostage** (user: "let's finish the last phase of the build plan"; code only, no VPS work). The four remaining items shared one theme, and it only became visible while building them: **§6's left-hand column is now complete.** Spell, define, gloss, pronounce, catch the common mistakes, review vocabulary, prove authorship — every daily-writing need works with the tunnel down. **The plan asked for "grammar lite as a fourth suggestion family", and the fourth family already existed**: Phase 8's deterministic `mechanics` pass was the plumbing, so this was the rule pack it had been waiting for rather than new machinery — preposition pairs, doubled comparatives, `people is`, plus per-pair L1 interference. **Q6 answered by hand-curating rather than mining LanguageTool**: that corpus is broad because it aims at recall, and this pack aims at the exact opposite, so every entry is a pairing wrong in essentially *all* contexts and the ones only *usually* wrong were left out on purpose — `married with` is a mistake until "married with children", `arrive to` wants at or in depending on the noun, `different than` is ordinary American English. Each rule is pinned in both directions, the guard case being the correct English next to the mistake. **The L1 rules are gated by pair, and the gating is what earns them their confidence** — *ter 30 anos* → "I am 30 years old" is a near-certainty for a Portuguese writer and only a guess for anyone else. The two zh rules the plan itself named are the ones this pack **refuses** to implement: dropped articles and he/she slips are not detectable from text alone ("She said he was late" is perfect whichever pronoun was meant), and flagging them would mean correcting correct writing. **The miscollocation list forced the session's one real design change.** It had to file as `collocation` rather than as its own family — same rail, same phrasing, and an accepted chunk plants in the garden exactly as the coach's would — but `type` had been quietly doubling as the answer to *which engine found this*, and that breaks the instant an offline rule proposes a collocation. Migration `0013_suggestion_source` splits the two apart: each pass now scopes its DELETE by engine, and the span tiebreak moved with it (an exact offline card beats an overlapping LLM one by source, not by type — an offline miscollocation is as exact as an offline comma). Without it the coach silently wiped every offline chunk on the page and the offline pass left the coach's rows to pile up; both directions are now tested, and a pre-0013 collocation row correctly backfills to the coach, since the offline list did not exist yet. **The daily invitation's whole substance is one stored date** — no count, no run of days, nothing that gets worse for being away, so a month away reads exactly like a day away; it lives in its own file because that is the property this feature would lose silently, and the test is named for it rather than for the query. Both answers spend the day's invitation, because being asked again after "not today" would make no a negotiation. **False friends are the one thing here that never becomes a card**: ~19 curated en↔pt entries, shown as a lavender block above the WordCard's definition and as at most one companion note per pass, with no `fix` anywhere — *actually* may well be the word she meant, and this is the mistake that makes a learner feel foolish rather than merely corrected. zh has none, which is the honest answer and not an unwritten one: the trap needs a shared script. Copy for the invitation and the false friends is greped by tests the same way the journal's is (*streak / in a row / 连续 / todos os dias*; *wrong / mistake / errado*) — the framing is the feature, and it is the part a future edit would undo while meaning well. Verified: go build/vet, `go test ./internal/...` clean, tsc, vite build, vitest 172/172 (30 new rule cases, 7 invitation, plus false-friend shape/tone guards), and a live throwaway DB on :8099 with **no LLM configured at all** — offline `did a mistake` → card → accept → garden card *made a mistake*, example bounded to its own corrected sentence, journal `kept:1`. ⚠️ **Not deployed and not seen in a browser**, and this one carries a migration, so it is a deploy rather than a rebuild. The pt-PT copy added here joins the pack a native speaker still has not reviewed.
|
||||
- 2026-07-27: **Phase 21 deployed — the pt-PT pair has a voice** (user: "continue the build plan"; scope chosen: deploy Phase 21 to the VPS rather than start Phase 22). The plan's remaining line was "Piper pt-PT voice instance on parodia", and it hid two things. **A language was still a code change**: read-aloud knew exactly two, named in the Config struct as `TTSEndpointZH`/`TTSVoiceZH`, so adding Portuguese meant editing Go to add Portuguese. Petal now discovers its Piper instances from the environment — English keeps the unsuffixed pair, everything else is `TTS_ENDPOINT_<LANG>`/`TTS_VOICE_<LANG>`, base tag only because an env var name cannot hold pt-PT's hyphen — and a language configured by halves is dropped rather than routed, so it reaches the client as "no voice, use Web Speech" instead of erroring on every tap. fr and es now cost a compose service and two `.env` lines. **And the voice itself repeated Phase 21's own lesson in a new place**: `pt_PT-tugão-medium` is the *only* European Portuguese voice in Piper's catalogue — the other five are Brazilian — so, exactly as with `dictionary-pt` packaging VERO, the default anyone reaches for ships the wrong country. Then it wouldn't download at all: `piper.download_voices` pastes the voice name into the HTTP request line and `http.client` encodes that as ASCII, so it dies with `UnicodeEncodeError` on the *ã* before a byte leaves the container — a failure that lands on precisely the one voice this pair needs and on no other. The entrypoint falls back to fetching the model and its config itself with the path percent-encoded, which is all the downloader was missing. **The slow replay** (§5e) went in while there: `slow: true` raises `length_scale` to ~4/3, and the pace is part of the **cache key** — without that, asking to hear slowly a word already heard at speed serves the fast clip back, which is the one request where the difference is the entire point. **The L1 voice asks the pack, not the letters**: a new `locale` field, because "comum" is spelled the same in both halves and a detector would have to guess — the same reason the gloss shows both directions. **Deploying is what finally ran the reverse lookup against real data**, the item the previous session left open because this laptop has no `dict.db`: *data* → "date", *comum* → "common; usual", *tarde* → "evening; afternoon", *ali* → "there", with *think*, *computer* and *garden* correctly silent; and *think* glossing to **pensar** first confirms Phase 20's sense-agreement ordering on the real 550 MB database rather than on a fixture. zh flipped back is byte-for-byte ECDICT again. go build/vet/test, tsc, vitest 125/125, vite; laptop smoke against two fake Pipers, then the real thing on the box. Her data untouched: 8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok, `schema_migrations` still at 11 (no migration in this phase). **Two things Phase 21 still owes, both said plainly**: the pack has not been read by a pt-PT speaker, and no pt-PT account exists — both writers are on the zh pair, so nothing she sees changed today and the browser half of the Portuguese experience has never had a human in front of it.
|
||||
- 2026-07-27: **Phase 21 (code half) — the pt-PT pair, and the plan's one-line assumption about the dictionary** (user: "let's continue the build plan"; scope confirmed: code only, the Piper voice and the deploy deferred, the pack written but flagged unreviewed). The plan said "Hunspell pt-PT vendored like en-US", and that turned out to be the load-bearing sentence. **nspell expands affixes eagerly on construction** — it materialises every surface form the moment you build it. English survives that; European Portuguese's 1,340 affix rules over 44,257 stems do not. Measured before deciding anything: ~340 MB of heap for the first 12,000 entries, and no return at all after three minutes on the whole file — over a gigabyte, in a browser, on a tablet. So the expansion moved to build time: `scripts/build_ptpt_dictionary.py` writes 1,039,058 forms, 2.66 MB gzipped, which the *same* nspell then reads in 842 ms using ~120 MB, and the runtime path stays byte-for-byte the English one. The `.aff` keeps only TRY/KEY/REP/MAP, which shape corrections rather than membership, so "telemovel" still corrects to "telemóvel". **A second thing the obvious route would have got wrong quietly**: npm's `dictionary-pt` is not European Portuguese — both it and `dictionary-pt-br` package VERO (Brasil), so vendoring the obvious package name ships Brazilian spellings under a pt-PT label. That is §3's pt-BR drift arriving through the *packaging* rather than through the model, and nobody reviewing the diff would see it. The real source is Projecto Natura's, packaged as `hunspell-pt-pt`; the build script now asserts the fault lines (`receção`/`húmido`/`pensámos` in, `recepção`/`úmido`/`ônibus`/`óptimo` out) before it writes a byte, and a vitest greps the built langpack for *sinônimo*, *arquivo*, *tela*, *você*. **Both-dictionaries spellcheck** landed as §3a specifies — flag only what every loaded dictionary rejects, interleave the correction pills so English can't fill all five — and dragged a smaller thing with it: the tokenizer had to become a property of the checker rather than a constant, because `[A-Za-z]` cuts "coração" into "cora", which is both silently unchecked *and* what a right-click would have looked up. The wide alphabet stays off for a writer with no Latin second language, where it could only earn her new squiggles. **Gloss both directions**: a Latin pair has no script boundary, so *data*, *sale* and *comum* are words on both sides and there is no honest way to know which she meant — Petal asks both and shows what answers, which needs no detector and therefore cannot be wrong about her writing. The reverse direction deliberately skips the English de-inflection walk, which over Portuguese would be right by accident and wrong by rule. **Writing the tests found the bug**: `extendedAlphabet` was a snapshot taken when the checker was built while `correct`/`suggest` read live — and her dictionary arrives *after* English, so the underlines would have been right while every lookup still resolved "cora". go build/vet/test, tsc, vite, vitest 116/116 clean; the shipped asset loaded in a real nspell; live smoke on a throwaway DB served both files and left the zh lookup untouched. **Two things outstanding and both said plainly**: the pack has *not* been read by a pt-PT speaker (SUGGESTIONS §3's own bar, and not one I can meet), and this laptop has no `dict.db`, so the reverse-lookup path is covered by a fixture rather than by a real collision — the first of those happens on the VPS.
|
||||
|
||||
+16
-7
@@ -23,11 +23,13 @@ runs them.
|
||||
|
||||
- **petal** — the single Go binary with the frontend embedded. Publishes no host
|
||||
port; Traefik is the only way in.
|
||||
- **piper-en** / **piper-zh** — read-aloud. Each Piper HTTP server loads exactly
|
||||
one voice, so English and Chinese are separate containers off one image, with
|
||||
the models cached in a shared volume. They sit on an internal network with no
|
||||
published ports, so only Petal can reach them. Adding pt-PT in Phase 21 is a
|
||||
fourth service, not a new image.
|
||||
- **piper-en** / **piper-zh** / **piper-pt** / **piper-fr** — read-aloud. Each
|
||||
Piper HTTP server loads exactly one voice, so every language is its own
|
||||
container off one image, with the models cached in a shared volume. They sit
|
||||
on an internal network with no published ports, so only Petal can reach them.
|
||||
Adding pt-PT in Phase 21 was a third service and fr in Phase 24 a fourth —
|
||||
never a new image, and since Phase 21 never any Go either (the languages are
|
||||
discovered from `TTS_ENDPOINT_<LANG>`/`TTS_VOICE_<LANG>`).
|
||||
|
||||
They run as containers rather than the host systemd units millenia uses because
|
||||
Piper was never actually installed on the VPS, and the `reala` account has no
|
||||
@@ -332,8 +334,8 @@ commonest English words; ECDICT covers essentially all of them and is in daily
|
||||
use by a real writer. `lexicon.Set.For` is where that decision lives — one
|
||||
`switch`, changed the day a comparison on her actual lookups says otherwise.
|
||||
|
||||
For pt-PT and French the same measurement reads 62%, which is why they use
|
||||
DreamDict: there is no alternative source for them at all.
|
||||
For pt-PT and French the same measurement reads 62% and 63%, which is why they
|
||||
use DreamDict: there is no alternative source for them at all.
|
||||
|
||||
### Rebuilding it
|
||||
|
||||
@@ -604,6 +606,13 @@ d=json.load(urllib.request.urlopen('https://huggingface.co/rhasspy/piper-voices/
|
||||
print([k for k in d if k.startswith('pt')])"
|
||||
```
|
||||
|
||||
**French: the opposite situation, and worth knowing it is.** Every `fr_*` voice
|
||||
in the catalogue is `fr_FR`, so there is no wrong country to land on by default
|
||||
and no Québec voice to choose instead; `fr_FR-siwis-medium` is picked to match
|
||||
the register of the other three rather than to avoid anything. The name is also
|
||||
plain ASCII, so the entrypoint's percent-encoded download fallback — which
|
||||
exists only because `tugão` broke `piper.download_voices` — never fires here.
|
||||
|
||||
**Slow replay.** `POST /api/tts` takes `slow: true`, which raises Piper's
|
||||
`length_scale` to about 4/3 (≈0.75× pace). It is a separate cache entry, not a
|
||||
playback-rate trick, so the slow clip is synthesized once and then instant.
|
||||
|
||||
@@ -53,13 +53,16 @@ LLM_TIMEOUT=90s
|
||||
# A language is routable only when both halves are set — a TTS_ENDPOINT_XX with
|
||||
# no TTS_VOICE_XX reads as "no voice for this language" and the browser's own
|
||||
# synthesizer takes over, rather than as an instance that errors on every
|
||||
# request. Adding fr or es is a compose service plus a pair of lines here.
|
||||
# request. Adding es is a compose service plus a pair of lines here.
|
||||
#
|
||||
# pt_PT-tugão-medium is the only European Portuguese voice Piper ships; every
|
||||
# other pt model in the catalogue is Brazilian.
|
||||
# other pt model in the catalogue is Brazilian. French has the opposite
|
||||
# property — every fr voice in the catalogue is fr_FR — so there is no wrong
|
||||
# country to land on and no non-ASCII name to trip the downloader.
|
||||
TTS_VOICE_EN=en_US-amy-medium
|
||||
TTS_VOICE_ZH=zh_CN-huayan-medium
|
||||
TTS_VOICE_PT=pt_PT-tugão-medium
|
||||
TTS_VOICE_FR=fr_FR-siwis-medium
|
||||
TTS_AUDIO_FORMAT=mp3
|
||||
TTS_TIMEOUT=15s
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ services:
|
||||
# change. <LANG> is the base tag — an env var name can't hold pt-PT's
|
||||
# hyphen, and there is one Portuguese voice loaded either way.
|
||||
TTS_ENDPOINT_PT: http://piper-pt:5000
|
||||
TTS_ENDPOINT_FR: http://piper-fr:5000
|
||||
# The sidecars run piper-tts 1.6.0, which serves synthesis on
|
||||
# /synthesize; millenia's older server keeps the default "/".
|
||||
TTS_PATH: /synthesize
|
||||
@@ -147,6 +148,24 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# French, for the fr pair. The opposite situation to Portuguese: every French
|
||||
# voice Piper ships is fr_FR, so there is no wrong country to land on by
|
||||
# default, and the name is plain ASCII so the entrypoint's percent-encoded
|
||||
# fallback (added for tugão) never has to fire. siwis-medium to match the
|
||||
# register of the other three.
|
||||
piper-fr:
|
||||
build:
|
||||
context: deploy/piper
|
||||
image: petal-piper:local
|
||||
container_name: petal-piper-fr
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PIPER_VOICE: ${TTS_VOICE_FR:-fr_FR-siwis-medium}
|
||||
volumes:
|
||||
- piper-voices:/voices
|
||||
networks:
|
||||
- internal
|
||||
|
||||
networks:
|
||||
# Created and owned by the host's Traefik stack.
|
||||
traefik:
|
||||
|
||||
@@ -31,6 +31,16 @@ func TestSetPairLang(t *testing.T) {
|
||||
t.Fatalf("pair_lang = %q, want pt-PT", u.PairLang)
|
||||
}
|
||||
|
||||
// Every pair with a langpack, not just the first one: this list and the
|
||||
// frontend's PACKS are two copies of the same fact, and the day they
|
||||
// disagree is the day she can pick a pair the app cannot render.
|
||||
if err := users.SetPairLang("bob", "fr"); err != nil {
|
||||
t.Fatalf("set fr: %v", err)
|
||||
}
|
||||
if u, _ := users.Get("bob"); u.PairLang != "fr" {
|
||||
t.Fatalf("pair_lang = %q, want fr", u.PairLang)
|
||||
}
|
||||
|
||||
// And back — a writer who tries a pair and doesn't like it must be able to
|
||||
// return, which is the whole reason the picker exists.
|
||||
if err := users.SetPairLang("bob", "zh"); err != nil {
|
||||
@@ -46,7 +56,10 @@ func TestSetPairLang(t *testing.T) {
|
||||
func TestSetPairLangRejectsUnshippedPairs(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
for _, lang := range []string{"fr", "es", "pt-BR", "klingon", "", " "} {
|
||||
// "es" is the real case here — the pair whose pack has not been written yet.
|
||||
// "pt-BR" is the near-miss that matters most: a Brazilian code must not be
|
||||
// quietly served European copy and a European voice.
|
||||
for _, lang := range []string{"es", "pt-BR", "fr-CA", "klingon", "", " "} {
|
||||
if err := users.SetPairLang("bob", lang); err == nil {
|
||||
t.Fatalf("stored unshipped pair %q", lang)
|
||||
}
|
||||
@@ -85,7 +98,7 @@ func TestUpdateMeHandlerRejects(t *testing.T) {
|
||||
_, users, _ := newStores(t)
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"unshipped pair": `{"pair_lang":"fr"}`,
|
||||
"unshipped pair": `{"pair_lang":"es"}`,
|
||||
"missing field": `{}`,
|
||||
"not json": `pt-PT`,
|
||||
} {
|
||||
|
||||
@@ -75,9 +75,9 @@ func (u *UserStore) MeHandler() http.HandlerFunc {
|
||||
// every pair the prompts know how to talk about, which is a cheap thing to add;
|
||||
// this one names the pairs Petal can render itself in, which requires a langpack
|
||||
// on the frontend. Accepting a code with no pack would leave her looking at
|
||||
// Chinese with no way back except another guess, so the server refuses it. fr
|
||||
// and es join this list on the day their packs land, not before.
|
||||
var shippedPairs = []string{"zh", "pt-PT"}
|
||||
// Chinese with no way back except another guess, so the server refuses it. es
|
||||
// joins this list on the day its pack lands, not before.
|
||||
var shippedPairs = []string{"zh", "pt-PT", "fr"}
|
||||
|
||||
func pairIsShipped(lang string) bool {
|
||||
for _, p := range shippedPairs {
|
||||
|
||||
@@ -14,6 +14,8 @@ func TestTTSVoicesDiscovery(t *testing.T) {
|
||||
"TTS_VOICE_ZH=zh_CN-huayan-medium",
|
||||
"TTS_ENDPOINT_PT=http://piper-pt:5000",
|
||||
"TTS_VOICE_PT=pt_PT-tugão-medium",
|
||||
"TTS_ENDPOINT_FR=http://piper-fr:5000",
|
||||
"TTS_VOICE_FR=fr_FR-siwis-medium",
|
||||
// Noise that must not become a language.
|
||||
"TTS_PATH=/synthesize",
|
||||
"PATH=/usr/bin",
|
||||
@@ -25,6 +27,9 @@ func TestTTSVoicesDiscovery(t *testing.T) {
|
||||
// cleanly rather than producing a double slash at every call site.
|
||||
"zh": {"http://piper-zh:5000", "zh_CN-huayan-medium"},
|
||||
"pt": {"http://piper-pt:5000", "pt_PT-tugão-medium"},
|
||||
// Phase 24's whole TTS change: a fourth language costs two lines here
|
||||
// and a compose service, and no Go at all.
|
||||
"fr": {"http://piper-fr:5000", "fr_FR-siwis-medium"},
|
||||
}
|
||||
if len(voices) != len(want) {
|
||||
t.Fatalf("discovered %v, want %v", voices, want)
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build one of Petal's browser spelling dictionaries from a Hunspell one.
|
||||
|
||||
Why this script exists at all
|
||||
----------------------------
|
||||
English is vendored the obvious way: `dictionary-en`'s `en.aff` + `en.dic` go
|
||||
into web/public/dictionaries/en and nspell reads them in the browser. The plan
|
||||
for Phase 21 said "Hunspell pt-PT vendored like en-US", and that turns out not to
|
||||
work, for a measured reason.
|
||||
|
||||
nspell expands affixes **eagerly at load time** — it materialises every surface
|
||||
form into a hash the moment you construct it. English gets away with this: ~50k
|
||||
stems and a small rule set. European Portuguese does not. `pt_PT.aff` carries
|
||||
1,340 affix rules (the full verb paradigm: six persons x a dozen tenses, plus
|
||||
diminutives, plus productive prefixes) over 44,257 stems. Measured on this
|
||||
machine, nspell needed ~340 MB of heap for the first 12,000 entries alone and had
|
||||
not returned after three minutes on the whole file; extrapolated, it wants well
|
||||
over a gigabyte. That is not something to hand a browser, still less a tablet.
|
||||
French is larger again: 5,600 affix rules over 84,140 stems.
|
||||
|
||||
So the expansion happens **here**, once, at build time, and the browser gets a
|
||||
flat word list it can load with no affix machinery at all. The runtime code path
|
||||
is then *identical* to English — same nspell, same interface — which is the real
|
||||
prize. The aff shipped alongside keeps only the suggestion-shaping directives
|
||||
(TRY/KEY/REP/MAP), so corrections still know that "cao" wants "ção" and that a
|
||||
missing acute accent is a near miss.
|
||||
|
||||
What Phase 24 had to add
|
||||
------------------------
|
||||
The build plan recorded that the pt-PT version of this script "generalizes" to
|
||||
French. It did not. It handled single-character flags and plain PFX/SFX and
|
||||
stopped on anything else — the right call, because `fr.aff` uses four of the
|
||||
things it stopped on, and getting any of them wrong changes which words are
|
||||
accepted:
|
||||
|
||||
* **`FLAG long`** — French flags are *two characters* (`S.`, `L'`, `Um`). The
|
||||
pt-PT reader took `set(flagstr)`, one flag per character, which on a French
|
||||
entry yields a bag of unrelated single letters: every entry would have been
|
||||
expanded through the wrong paradigm. This is the one that fails silently.
|
||||
* **Continuation flags** — pt-PT's affixes append plain text, so that script
|
||||
dropped anything after a `/` and asserted the drop was safe. French really
|
||||
does affix an affixed form: `PFX Um 0 0/S.` says the prefixed form then takes
|
||||
the plural suffix, and the elision prefixes arrive the same way from the
|
||||
other side (`SFX ... ait/n'q'l'm't's'`).
|
||||
* **`NEEDAFFIX`** — French marks thousands of stems "not a word on its own"
|
||||
(`Allemagne/S.()`), the bare form arriving instead through a zero-append
|
||||
rule. Ignoring the flag accepts stems the real dictionary rejects.
|
||||
* **`FULLSTRIP`** — a rule may strip the whole stem.
|
||||
|
||||
`CIRCUMFIX` and `FORBIDDENWORD` are *declared* in `fr.aff` and used by nothing,
|
||||
which this script asserts rather than assumes: an upstream release that started
|
||||
using either would otherwise change what is accepted without changing this file.
|
||||
`KEEPCASE` and `NOSUGGEST` are honoured by being ignored on purpose — they shape
|
||||
casing and suggestions, not membership, and a NOSUGGEST word is still a word.
|
||||
|
||||
Elision is handled at lookup, not here — and that is the size decision
|
||||
----------------------------------------------------------------------
|
||||
Most of French's affix machinery by volume is elision: `l'`, `d'`, `qu'`, `j'`,
|
||||
`n'`, `s'`, `jusqu'`, `puisqu'`. Hunspell treats `l'arbre` as one word, so a
|
||||
faithful expansion carries much of the language thirty-four times over — and
|
||||
Petal's tokenizer keeps internal apostrophes, so `l'arbre` really does arrive at
|
||||
the dictionary as one token and really would be underlined if it were absent.
|
||||
|
||||
Both halves were built and measured. Keeping the elided forms: **3,159,832 forms,
|
||||
8.25 MB gzipped**, ~45 MB of text for nspell to hash on a tablet. Dropping them:
|
||||
**473,326 forms, 1.19 MB gzipped**. The elided seven-eighths are not new words —
|
||||
they are thirteen little words glued to words already in the list — so the third
|
||||
option is the one taken: rules whose append carries an apostrophe are skipped
|
||||
here (the count is printed), and `withElision` in `useSpellChecker.ts` splits a
|
||||
token at a *known clitic* and checks the remainder. `l'arbre` costs one extra
|
||||
lookup instead of seven megabytes, and `zzz'arbre` is still flagged because
|
||||
`zzz` is not one of the thirteen.
|
||||
|
||||
Stems that carry an apostrophe of their own — `aujourd'hui`, `quelqu'un`,
|
||||
`presqu'île`, `prud'homme` — are dictionary entries rather than affixed forms,
|
||||
so they are kept verbatim and matched directly. `entr'aide` and `grand'mère` are
|
||||
absent for the same reason they are absent from Dicollecte: modern French spells
|
||||
them `entraide` and `grand-mère`.
|
||||
|
||||
Choosing the source
|
||||
-------------------
|
||||
Both languages have a trap here, and they are different traps.
|
||||
|
||||
**pt-PT: the wrong country.** npm's `dictionary-pt` is not European Portuguese.
|
||||
Both it and `dictionary-pt-br` package VERO ("Verificador Ortográfico Livre",
|
||||
Brasil), so vendoring the obvious npm name would have shipped Brazilian spellings
|
||||
under a pt-PT label — the pt-BR drift SUGGESTIONS.md §3 warns about, arriving
|
||||
through the packaging rather than through the model. The authentic dictionary is
|
||||
the Projecto Natura one (Universidade do Minho) that LibreOffice ships and Debian
|
||||
packages as `hunspell-pt-pt`; its aff declares `LANG pt_PT`.
|
||||
|
||||
**fr: the wrong side of an argument the French have not settled.** The regional
|
||||
question turns out to be a non-question — Debian's `fr_FR`, `fr_CA`, `fr_BE`,
|
||||
`fr_CH`, `fr_LU` and `fr_MC` are all symlinks to one `fr.dic`, so unlike pt there
|
||||
is no country here to get wrong. What there is instead is the 1990 spelling
|
||||
reform, packaged three ways: `hunspell-fr-classical` (traditional), `-revised`
|
||||
(reform only) and `-comprehensive` (both). Petal ships **comprehensive**, because
|
||||
Petal never corrects her French — the only thing this dictionary can do is
|
||||
underline something. *coût* and *cout* are both correct French, taught in
|
||||
different decades to different people, and a writing companion has no business
|
||||
underlining one of them to take a side. The `fr` MUST_ACCEPT list is written to
|
||||
*prove* which package was used: classical rejects `cout`, revised rejects `coût`,
|
||||
and only comprehensive accepts both.
|
||||
|
||||
Licensing: pt-PT is GPL-2 or LGPL-2.1 or MPL-1.1, (c) José João de Almeida, Rui
|
||||
Vilela, Alberto Simões. fr is MPL-2.0, (c) 2007-2018 the Dicollecte contributors
|
||||
(grammalecte.net). The upstream copyright file is vendored beside each output.
|
||||
|
||||
Usage
|
||||
-----
|
||||
apt-get download hunspell-fr-comprehensive # or hunspell-pt-pt
|
||||
dpkg-deb -x hunspell-fr-comprehensive_*.deb src
|
||||
python3 scripts/build_hunspell_dictionary.py fr \\
|
||||
src/usr/share/hunspell/fr.aff \\
|
||||
src/usr/share/hunspell/fr.dic \\
|
||||
web/public/dictionaries/fr
|
||||
"""
|
||||
import gzip
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
# Directives worth keeping in the shipped aff. These shape *suggestions*, not
|
||||
# membership: TRY orders the alphabet the corrector tries, KEY knows which keys
|
||||
# are adjacent, REP holds the language's own confusions (cao/ção, ss/ç), and MAP
|
||||
# says an accented vowel and its bare form are the same letter for scoring —
|
||||
# which is most of what an ESL writer gets wrong in either language.
|
||||
KEEP_DIRECTIVES = ("SET", "TRY", "KEY", "REP", "MAP", "WORDCHARS")
|
||||
|
||||
# Directives that would change which words are *accepted* and that this expander
|
||||
# does not implement. If a future upstream release starts using one, the output
|
||||
# would silently disagree with the real dictionary, so the build stops instead.
|
||||
UNSUPPORTED = (
|
||||
"COMPOUNDFLAG", "COMPOUNDMIN", "COMPOUNDRULE", "COMPOUNDBEGIN",
|
||||
"ONLYINCOMPOUND", "PSEUDOROOT", "AF", "AM",
|
||||
)
|
||||
|
||||
APOSTROPHES = "'’"
|
||||
|
||||
|
||||
class Aff:
|
||||
"""The parts of an .aff file that decide which words exist."""
|
||||
|
||||
def __init__(self):
|
||||
self.pfx = {} # flag -> [(strip, append, condition, continuation)]
|
||||
self.sfx = {}
|
||||
# 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
|
||||
self.forbidden = None
|
||||
self.dropped_apostrophe_rules = 0
|
||||
|
||||
|
||||
def parse_flags(raw, kind):
|
||||
"""Split a flag string into flags, per the aff's FLAG declaration."""
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return set()
|
||||
if kind == "long":
|
||||
# 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)
|
||||
|
||||
|
||||
def parse_aff(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
|
||||
aff = Aff()
|
||||
|
||||
# FLAG has to be known before anything containing a flag is read, and it can
|
||||
# sit anywhere in the file. So: header pass first, rules second.
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
head = parts[0]
|
||||
if head in UNSUPPORTED:
|
||||
raise SystemExit(
|
||||
f"{path}: unsupported directive {head!r} — this expander handles "
|
||||
"PFX/SFX affixation with continuation flags, and honouring "
|
||||
f"{head} would change which words are accepted. Extend the "
|
||||
"script before shipping."
|
||||
)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
if head == "FLAG":
|
||||
aff.flag_kind = parts[1]
|
||||
if aff.flag_kind not in ("long", "num", "UTF-8"):
|
||||
raise SystemExit(f"{path}: unknown FLAG type {aff.flag_kind!r}")
|
||||
elif head == "NEEDAFFIX":
|
||||
aff.needaffix = parts[1]
|
||||
elif head == "CIRCUMFIX":
|
||||
aff.circumfix = parts[1]
|
||||
elif head == "FORBIDDENWORD":
|
||||
aff.forbidden = parts[1]
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
parts = lines[i].split()
|
||||
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
|
||||
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()
|
||||
strip = "" if p[2] == "0" else p[2]
|
||||
append, _, cont_raw = p[3].partition("/")
|
||||
if append == "0":
|
||||
append = ""
|
||||
# Elision. See the header: these forms are `l'` and its twelve
|
||||
# siblings glued to words already in the list, they multiply the
|
||||
# download by seven, and `withElision` reconstructs them at
|
||||
# lookup for the cost of one extra hash probe.
|
||||
if any(a in append for a in APOSTROPHES):
|
||||
aff.dropped_apostrophe_rules += 1
|
||||
continue
|
||||
cont = parse_flags(cont_raw, aff.flag_kind)
|
||||
if aff.circumfix and aff.circumfix in cont:
|
||||
raise SystemExit(
|
||||
f"{path}: CIRCUMFIX is used by a {kind} {flag} rule. It "
|
||||
"was declared-but-unused when this expander was written "
|
||||
"and is not implemented; honouring it would change which "
|
||||
"words are accepted."
|
||||
)
|
||||
cond = p[4] if len(p) > 4 else "."
|
||||
anchored = ("^" + cond) if kind == "PFX" else (cond + "$")
|
||||
rules.append((strip, append, re.compile(anchored), cont))
|
||||
i += count + 1
|
||||
continue
|
||||
i += 1
|
||||
return aff
|
||||
|
||||
|
||||
def apply_suffix(word, rules):
|
||||
"""Every (form, continuation flags) a suffix table yields for `word`."""
|
||||
out = []
|
||||
for strip, append, cond, cont in rules:
|
||||
if strip and not word.endswith(strip):
|
||||
continue
|
||||
if not cond.search(word):
|
||||
continue
|
||||
stem = word[: len(word) - len(strip)] if strip else word
|
||||
out.append((stem + append, cont))
|
||||
return out
|
||||
|
||||
|
||||
def apply_prefix(word, rules):
|
||||
out = []
|
||||
for strip, append, cond, cont in rules:
|
||||
if strip and not word.startswith(strip):
|
||||
continue
|
||||
if not cond.search(word):
|
||||
continue
|
||||
stem = word[len(strip):] if strip else word
|
||||
out.append((append + stem, cont))
|
||||
return out
|
||||
|
||||
|
||||
def expand_entry(word, flags, aff, out):
|
||||
"""Add every surface form of one dictionary entry to `out`.
|
||||
|
||||
Hunspell's model without compounding: a form is the stem plus at most one
|
||||
prefix and at most one suffix. A flag reaches an affix either from the stem's
|
||||
own flags or from the continuation flags of the affix applied on the other
|
||||
side; when both sides apply, both rules must be declared cross-product.
|
||||
|
||||
NEEDAFFIX is why the bare form is not simply added: the flag says *this* form
|
||||
is not a word, only whatever can be built from it — and it arrives both on
|
||||
stems and on continuations.
|
||||
"""
|
||||
def is_word(carried):
|
||||
return not (aff.needaffix and aff.needaffix in carried)
|
||||
|
||||
if is_word(flags):
|
||||
out.add(word)
|
||||
|
||||
suffixed = [] # (form, flag, continuation flags)
|
||||
for f in flags:
|
||||
if f in aff.sfx:
|
||||
for form, cont in apply_suffix(word, aff.sfx[f]):
|
||||
suffixed.append((form, f, cont))
|
||||
if is_word(cont):
|
||||
out.add(form)
|
||||
|
||||
prefixed = []
|
||||
for f in flags:
|
||||
if f in aff.pfx:
|
||||
for form, cont in apply_prefix(word, aff.pfx[f]):
|
||||
prefixed.append((form, f, cont))
|
||||
if is_word(cont):
|
||||
out.add(form)
|
||||
|
||||
# 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_pfx.get(pf):
|
||||
continue
|
||||
for f in flags | pcont:
|
||||
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_sfx.get(sf):
|
||||
continue
|
||||
for f in scont:
|
||||
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):
|
||||
aff = parse_aff(aff_path)
|
||||
forms = set()
|
||||
needaffix_stems = 0
|
||||
with open(dic_path, encoding="utf-8") as fh:
|
||||
fh.readline() # leading entry count, not a word
|
||||
for raw in fh:
|
||||
# Morphological fields (po:nom is:fem) follow the entry, separated by
|
||||
# a tab in pt-PT and by a space in fr.
|
||||
entry = raw.strip().split("\t")[0].split(" ")[0]
|
||||
if not entry:
|
||||
continue
|
||||
word, _, flagstr = entry.partition("/")
|
||||
word = word.strip()
|
||||
if not word:
|
||||
continue
|
||||
flags = parse_flags(flagstr, aff.flag_kind)
|
||||
if aff.forbidden and aff.forbidden in flags:
|
||||
raise SystemExit(
|
||||
f"{dic_path}: FORBIDDENWORD is in use ({word!r}). It was "
|
||||
"declared-but-unused when this expander was written; the "
|
||||
"forms it removes would be wrongly accepted."
|
||||
)
|
||||
if aff.needaffix and aff.needaffix in flags:
|
||||
needaffix_stems += 1
|
||||
expand_entry(word, flags, aff, forms)
|
||||
|
||||
# NFC, because the aff's own ICONV table normalises decomposed accents on the
|
||||
# way in and the browser hands nspell whatever the keyboard produced.
|
||||
forms = {unicodedata.normalize("NFC", f) for f in forms}
|
||||
return forms, aff, needaffix_stems
|
||||
|
||||
|
||||
def shipped_aff(aff_path):
|
||||
keep = []
|
||||
for line in open(aff_path, encoding="utf-8").read().splitlines():
|
||||
head = line.split()[0] if line.split() else ""
|
||||
if head in KEEP_DIRECTIVES:
|
||||
keep.append(line)
|
||||
return "\n".join(keep) + "\n"
|
||||
|
||||
|
||||
# Words the built list must accept, and must reject, before it is written. Each
|
||||
# set is chosen to fail loudly on the *specific* wrong source that language has a
|
||||
# packaged, plausible way of reaching — not to spot-check spelling in general.
|
||||
PROFILES = {
|
||||
# The pt-PT/pt-BR fault lines: post-Acordo spellings, the European lexicon,
|
||||
# and the first-person-plural preterite accent that only pt-PT writes.
|
||||
"pt-PT": {
|
||||
"accept": ("receção", "húmido", "telemóvel", "autocarro", "comboio",
|
||||
"ótimo", "pensámos", "escrevêssemos", "jardim"),
|
||||
"reject": ("recepção", "úmido", "ônibus", "óptimo"),
|
||||
"wrong": "this does not look like European Portuguese",
|
||||
},
|
||||
# Which of the three 1990-reform packagings this is. `coût`/`cout` and
|
||||
# `paraître`/`paraitre` are each accepted by exactly one of classical and
|
||||
# revised, so a build accepting all four is comprehensive and one that drops
|
||||
# any of them is not. `Allemagne` is a NEEDAFFIX stem reachable only through
|
||||
# a zero-append rule and `km` only through a prefix continuation, so between
|
||||
# them they also check that this expander honoured the two features the
|
||||
# pt-PT one refused.
|
||||
"fr": {
|
||||
"accept": ("coût", "cout", "paraître", "paraitre", "nénuphar", "nénufar",
|
||||
"oignon", "ognon", "événement", "évènement", "jardin",
|
||||
"Allemagne", "écrivissions", "km"),
|
||||
"reject": ("jardinn", "écrivaitz", "xyzzyque"),
|
||||
"wrong": "this does not look like the comprehensive French dictionary",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main(lang, aff_path, dic_path, out_dir):
|
||||
profile = PROFILES.get(lang)
|
||||
if profile is None:
|
||||
raise SystemExit(f"no profile for {lang!r}; known: {', '.join(PROFILES)}")
|
||||
|
||||
forms, aff, needaffix_stems = expand(aff_path, dic_path)
|
||||
|
||||
missing = [w for w in profile["accept"] if w not in forms]
|
||||
present = [w for w in profile["reject"] if w in forms]
|
||||
if missing or present:
|
||||
raise SystemExit(
|
||||
f"{profile['wrong']}: missing {missing}, unexpectedly present {present}"
|
||||
)
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
ordered = sorted(forms)
|
||||
body = f"{len(ordered)}\n" + "\n".join(ordered) + "\n"
|
||||
|
||||
dic_out = os.path.join(out_dir, f"{lang}.dic.gz")
|
||||
# mtime=0 so rebuilding identical input produces an identical file — a
|
||||
# vendored asset that changes on every build is noise in the diff.
|
||||
with gzip.GzipFile(dic_out, "wb", compresslevel=9, mtime=0) as fh:
|
||||
fh.write(body.encode("utf-8"))
|
||||
|
||||
aff_out = os.path.join(out_dir, f"{lang}.aff")
|
||||
with open(aff_out, "w", encoding="utf-8") as fh:
|
||||
fh.write(shipped_aff(aff_path))
|
||||
|
||||
print(f"{len(ordered)} forms -> {dic_out} "
|
||||
f"({os.path.getsize(dic_out) / 1e6:.2f} MB gzipped); "
|
||||
f"{needaffix_stems} NEEDAFFIX stems, "
|
||||
f"{aff.dropped_apostrophe_rules} elision rules skipped", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 5:
|
||||
raise SystemExit(
|
||||
"usage: build_hunspell_dictionary.py <lang> <aff> <dic> <out-dir>\n"
|
||||
f" lang is one of: {', '.join(PROFILES)}"
|
||||
)
|
||||
main(*sys.argv[1:5])
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build Petal's browser pt-PT spelling dictionary from the LibreOffice Hunspell one.
|
||||
|
||||
Why this script exists at all
|
||||
----------------------------
|
||||
English is vendored the obvious way: `dictionary-en`'s `en.aff` + `en.dic` go
|
||||
into web/public/dictionaries/en and nspell reads them in the browser. The plan
|
||||
for Phase 21 said "Hunspell pt-PT vendored like en-US", and that turns out not to
|
||||
work, for a measured reason.
|
||||
|
||||
nspell expands affixes **eagerly at load time** — it materialises every surface
|
||||
form into a hash the moment you construct it. English gets away with this: ~50k
|
||||
stems and a small rule set. European Portuguese does not. `pt_PT.aff` carries
|
||||
1,340 affix rules (the full verb paradigm: six persons x a dozen tenses, plus
|
||||
diminutives, plus productive prefixes) over 44,257 stems. Measured on this
|
||||
machine, nspell needed ~340 MB of heap for the first 12,000 entries alone and had
|
||||
not returned after three minutes on the whole file; extrapolated, it wants well
|
||||
over a gigabyte. That is not something to hand a browser, still less a tablet.
|
||||
|
||||
So the expansion happens **here**, once, at build time, and the browser gets a
|
||||
flat word list it can load with no affix machinery at all: 1,039,058 forms, 15 MB
|
||||
of text, 2.7 MB gzipped, which nspell reads in ~0.8s using ~120 MB. The runtime
|
||||
code path is then *identical* to English — same nspell, same interface — which is
|
||||
the real prize. The aff we ship alongside keeps only the suggestion-shaping
|
||||
directives (TRY/KEY/REP/MAP), so corrections still know that "cao" wants "ção"
|
||||
and that a missing acute accent is a near miss.
|
||||
|
||||
Choosing the source
|
||||
-------------------
|
||||
npm's `dictionary-pt` is **not** European Portuguese. Both it and
|
||||
`dictionary-pt-br` package VERO ("Verificador Ortográfico Livre", Brasil), so
|
||||
vendoring the obvious npm name would have shipped Brazilian spellings under a
|
||||
pt-PT label — exactly the pt-BR drift SUGGESTIONS.md §3 warns about, arriving
|
||||
through the packaging rather than through the model.
|
||||
|
||||
The authentic dictionary is the Projecto Natura one (Universidade do Minho) that
|
||||
LibreOffice ships and Debian packages as `hunspell-pt-pt`. Its aff declares
|
||||
`LANG pt_PT`. Spot-checked against the built list, it accepts `receção`,
|
||||
`húmido`, `telemóvel`, `autocarro`, `comboio`, `ótimo` and `pensámos`, and
|
||||
rejects `recepção`, `úmido`, `ônibus` and `óptimo` — post-Acordo European
|
||||
Portuguese, which is what a pt-PT writer should be held to.
|
||||
|
||||
Licensing: GPL-2 or LGPL-2.1 or MPL-1.1, (c) José João de Almeida, Rui Vilela,
|
||||
Alberto Simões. The upstream copyright file is vendored beside the output.
|
||||
|
||||
Usage
|
||||
-----
|
||||
apt-get download hunspell-pt-pt # or take pt_PT.aff/.dic from LibreOffice
|
||||
dpkg-deb -x hunspell-pt-pt_*.deb ptpt
|
||||
python3 scripts/build_ptpt_dictionary.py \
|
||||
ptpt/usr/share/hunspell/pt_PT.aff \
|
||||
ptpt/usr/share/hunspell/pt_PT.dic \
|
||||
web/public/dictionaries/pt-PT
|
||||
|
||||
Only the directives pt_PT.aff actually uses are implemented: PFX/SFX with
|
||||
single-character flags, strip/append/condition, and cross-product. It carries no
|
||||
compounding, no flag aliases and no NEEDAFFIX, so there is nothing else to
|
||||
honour — the script asserts that rather than assuming it.
|
||||
"""
|
||||
import gzip
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Directives worth keeping in the shipped aff. These shape *suggestions*, not
|
||||
# membership: TRY orders the alphabet the corrector tries, KEY knows which keys
|
||||
# are adjacent, REP holds Portuguese-specific confusions (cao/ção, ss/ç), and MAP
|
||||
# says an accented vowel and its bare form are the same letter for scoring —
|
||||
# which is most of what an ESL writer gets wrong in Portuguese.
|
||||
KEEP_DIRECTIVES = ("SET", "TRY", "KEY", "REP", "MAP", "WORDCHARS")
|
||||
|
||||
# Directives that would change which words are *accepted*. If a future upstream
|
||||
# release starts using one, this script's output would silently disagree with
|
||||
# the real dictionary, so it stops instead.
|
||||
UNSUPPORTED = (
|
||||
"COMPOUNDFLAG", "COMPOUNDMIN", "COMPOUNDRULE", "COMPOUNDBEGIN",
|
||||
"ONLYINCOMPOUND", "NEEDAFFIX", "PSEUDOROOT", "CIRCUMFIX",
|
||||
"FORBIDDENWORD", "AF", "AM", "FLAG",
|
||||
)
|
||||
|
||||
|
||||
def parse_aff(path):
|
||||
"""Return (prefix rules, suffix rules, cross-product flags) keyed by flag."""
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
lines = fh.read().splitlines()
|
||||
|
||||
for line in lines:
|
||||
head = line.split()[0] if line.split() else ""
|
||||
if head in UNSUPPORTED:
|
||||
raise SystemExit(
|
||||
f"{path}: unsupported directive {head!r} — this expander only "
|
||||
"handles plain PFX/SFX affixation, and honouring it would change "
|
||||
"which words are accepted. Extend the script before shipping."
|
||||
)
|
||||
|
||||
pfx, sfx, cross = {}, {}, {}
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
parts = lines[i].split()
|
||||
if parts and parts[0] in ("PFX", "SFX"):
|
||||
kind, flag, cross_flag, count = parts[0], parts[1], parts[2], int(parts[3])
|
||||
table = pfx if kind == "PFX" else sfx
|
||||
cross[flag] = cross_flag == "Y"
|
||||
rules = table.setdefault(flag, [])
|
||||
for j in range(1, count + 1):
|
||||
p = lines[i + j].split()
|
||||
strip = "" if p[2] == "0" else p[2]
|
||||
# The appended text may carry its own continuation flags after a
|
||||
# slash (append/FLAGS). We drop them: honouring them would mean
|
||||
# affixing an affixed form, which pt_PT.aff does not do.
|
||||
append = "" if p[3] == "0" else p[3].split("/")[0]
|
||||
cond = p[4] if len(p) > 4 else "."
|
||||
anchored = ("^" + cond) if kind == "PFX" else (cond + "$")
|
||||
rules.append((strip, append, re.compile(anchored)))
|
||||
i += count + 1
|
||||
continue
|
||||
i += 1
|
||||
return pfx, sfx, cross
|
||||
|
||||
|
||||
def apply_suffix(word, rules):
|
||||
out = []
|
||||
for strip, append, cond in rules:
|
||||
if strip and not word.endswith(strip):
|
||||
continue
|
||||
if not cond.search(word):
|
||||
continue
|
||||
stem = word[: len(word) - len(strip)] if strip else word
|
||||
out.append(stem + append)
|
||||
return out
|
||||
|
||||
|
||||
def apply_prefix(word, rules):
|
||||
out = []
|
||||
for strip, append, cond in rules:
|
||||
if strip and not word.startswith(strip):
|
||||
continue
|
||||
if not cond.search(word):
|
||||
continue
|
||||
stem = word[len(strip):] if strip else word
|
||||
out.append(append + stem)
|
||||
return out
|
||||
|
||||
|
||||
def expand(aff_path, dic_path):
|
||||
pfx, sfx, cross = parse_aff(aff_path)
|
||||
forms = set()
|
||||
with open(dic_path, encoding="utf-8") as fh:
|
||||
fh.readline() # leading entry count, not a word
|
||||
for raw in fh:
|
||||
entry = raw.strip().split("\t")[0] # drop morphological fields
|
||||
if not entry:
|
||||
continue
|
||||
word, _, flagstr = entry.partition("/")
|
||||
word = word.strip()
|
||||
if not word:
|
||||
continue
|
||||
flags = set(flagstr.strip())
|
||||
forms.add(word)
|
||||
|
||||
for f in flags:
|
||||
if f in sfx:
|
||||
forms.update(apply_suffix(word, sfx[f]))
|
||||
|
||||
prefixed = []
|
||||
for f in flags:
|
||||
if f in pfx:
|
||||
prefixed.extend(apply_prefix(word, pfx[f]))
|
||||
forms.update(prefixed)
|
||||
|
||||
# Cross-product: a prefixed form may also take a suffix, but only
|
||||
# when both rules are declared cross-product ("Y").
|
||||
for f in flags:
|
||||
if f in pfx and cross.get(f):
|
||||
for base in apply_prefix(word, pfx[f]):
|
||||
for g in flags:
|
||||
if g in sfx and cross.get(g):
|
||||
forms.update(apply_suffix(base, sfx[g]))
|
||||
return forms
|
||||
|
||||
|
||||
def shipped_aff(aff_path):
|
||||
keep = []
|
||||
for line in open(aff_path, encoding="utf-8").read().splitlines():
|
||||
head = line.split()[0] if line.split() else ""
|
||||
if head in KEEP_DIRECTIVES:
|
||||
keep.append(line)
|
||||
return "\n".join(keep) + "\n"
|
||||
|
||||
|
||||
# Words the built list must accept, and must reject, before it is written. These
|
||||
# are the pt-PT/pt-BR fault lines: post-Acordo spellings, the European lexicon,
|
||||
# and the first-person-plural preterite accent that only pt-PT writes. A source
|
||||
# dictionary that fails these is not the one this script is for.
|
||||
MUST_ACCEPT = ("receção", "húmido", "telemóvel", "autocarro", "comboio",
|
||||
"ótimo", "pensámos", "escrevêssemos", "jardim")
|
||||
MUST_REJECT = ("recepção", "úmido", "ônibus", "óptimo")
|
||||
|
||||
|
||||
def main(aff_path, dic_path, out_dir):
|
||||
forms = expand(aff_path, dic_path)
|
||||
|
||||
missing = [w for w in MUST_ACCEPT if w not in forms]
|
||||
present = [w for w in MUST_REJECT if w in forms]
|
||||
if missing or present:
|
||||
raise SystemExit(
|
||||
"this does not look like European Portuguese: "
|
||||
f"missing {missing}, unexpectedly present {present}"
|
||||
)
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
ordered = sorted(forms)
|
||||
body = f"{len(ordered)}\n" + "\n".join(ordered) + "\n"
|
||||
|
||||
dic_out = os.path.join(out_dir, "pt-PT.dic.gz")
|
||||
# mtime=0 so rebuilding identical input produces an identical file — a
|
||||
# vendored asset that changes on every build is noise in the diff.
|
||||
with gzip.GzipFile(dic_out, "wb", compresslevel=9, mtime=0) as fh:
|
||||
fh.write(body.encode("utf-8"))
|
||||
|
||||
aff_out = os.path.join(out_dir, "pt-PT.aff")
|
||||
with open(aff_out, "w", encoding="utf-8") as fh:
|
||||
fh.write(shipped_aff(aff_path))
|
||||
|
||||
print(f"{len(ordered)} forms -> {dic_out} "
|
||||
f"({os.path.getsize(dic_out) / 1e6:.2f} MB gzipped)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 4:
|
||||
raise SystemExit(__doc__.strip().splitlines()[-1])
|
||||
main(*sys.argv[1:4])
|
||||
@@ -0,0 +1,49 @@
|
||||
French spelling dictionary
|
||||
==========================
|
||||
|
||||
The word list in `fr.dic.gz` and the suggestion directives in `fr.aff` are
|
||||
derived from the Dicollecte / Grammalecte Hunspell dictionary for French
|
||||
(`fr.aff` / `fr.dic`), as packaged by Debian/Ubuntu in
|
||||
`hunspell-fr-comprehensive`.
|
||||
|
||||
Copyright (C) 2007-2018 the Dicollecte contributors
|
||||
(full list at
|
||||
https://grammalecte.net/members.php?prj=fr)
|
||||
Dictionary author: Olivier R.
|
||||
|
||||
License: MPL-2.0
|
||||
This Source Code Form is subject to the terms of the Mozilla
|
||||
Public License, v. 2.0. If a copy of the MPL was not distributed
|
||||
with this file, You can obtain one at
|
||||
http://mozilla.org/MPL/2.0/.
|
||||
|
||||
Upstream: https://grammalecte.net/home.php?prj=fr
|
||||
|
||||
Which of the three
|
||||
------------------
|
||||
|
||||
Debian packages this dictionary three ways, by how it treats the 1990 spelling
|
||||
reform: `hunspell-fr-classical` (traditional spellings), `hunspell-fr-revised`
|
||||
(reform spellings) and `hunspell-fr-comprehensive` (both). Petal ships the
|
||||
**comprehensive** one, because Petal never corrects her French — the only thing
|
||||
this dictionary can do is underline something, and *coût* and *cout* are both
|
||||
correct French. The regional packages (`fr_FR`, `fr_CA`, `fr_BE`, `fr_CH`,
|
||||
`fr_LU`, `fr_MC`) are all symlinks to the same word list, so there is no
|
||||
regional choice being made here.
|
||||
|
||||
What Petal changed
|
||||
------------------
|
||||
|
||||
`scripts/build_hunspell_dictionary.py` applies the upstream affix rules ahead of
|
||||
time — Hunspell's PFX/SFX expansion run once at build time instead of once per
|
||||
browser — and writes the resulting 473,326 surface forms as a flat word list.
|
||||
The shipped `.aff` keeps only upstream's TRY/KEY/REP/MAP/WORDCHARS lines, which
|
||||
shape *corrections* rather than membership.
|
||||
|
||||
One thing about membership did change, and it is reversible at lookup rather
|
||||
than lost: the elided forms (`l'arbre`, `qu'elle`, `jusqu'ici`) are **not** in
|
||||
the word list. Expanding them costs 8.25 MB gzipped against 1.19 MB, and they
|
||||
are not new words — they are thirteen clitics glued to words already present —
|
||||
so `withElision` in `web/src/hooks/useSpellChecker.ts` splits the token and
|
||||
checks the remainder instead. Stems that carry an apostrophe of their own
|
||||
(`aujourd'hui`, `quelqu'un`, `presqu'île`, `prud'homme`) are kept verbatim.
|
||||
@@ -0,0 +1,141 @@
|
||||
SET UTF-8
|
||||
WORDCHARS -’'1234567890.
|
||||
TRY esntiarulodcpmévqfgbhàxèjyêMILzACçôîPâùJFSûBVœRDGNETHXkïOwKWYUëQÉZŒüãÎáöóÈíæÅñäśńÿ
|
||||
MAP 25
|
||||
MAP aàâäAÀÂÄ
|
||||
MAP eéèêëEÉÈÊË
|
||||
MAP iîïyIÎÏY
|
||||
MAP oôöOÔÖ
|
||||
MAP uùûüUÙÛÜ
|
||||
MAP cçCÇ
|
||||
MAP bB
|
||||
MAP dD
|
||||
MAP fF
|
||||
MAP gG
|
||||
MAP hH
|
||||
MAP jJ
|
||||
MAP kK
|
||||
MAP lL
|
||||
MAP mM
|
||||
MAP nN
|
||||
MAP pP
|
||||
MAP qQ
|
||||
MAP rR
|
||||
MAP sS
|
||||
MAP tT
|
||||
MAP vV
|
||||
MAP wW
|
||||
MAP xX
|
||||
MAP zZ
|
||||
REP 110
|
||||
REP a â
|
||||
REP â a
|
||||
REP e é
|
||||
REP é e
|
||||
REP e ê
|
||||
REP ê e
|
||||
REP e è
|
||||
REP è e
|
||||
REP i î
|
||||
REP î i
|
||||
REP o ô
|
||||
REP ô o
|
||||
REP u û
|
||||
REP û u
|
||||
REP A Â
|
||||
REP Â A
|
||||
REP E É
|
||||
REP É E
|
||||
REP E Ê
|
||||
REP Ê E
|
||||
REP E È
|
||||
REP È E
|
||||
REP I Î
|
||||
REP Î I
|
||||
REP O Ô
|
||||
REP Ô O
|
||||
REP U Û
|
||||
REP Û U
|
||||
REP ^Ca$ Ça
|
||||
REP ^l l'
|
||||
REP ^d d'
|
||||
REP ^n n'
|
||||
REP ^s s'
|
||||
REP ^j j'
|
||||
REP ^m m'
|
||||
REP ^t t'
|
||||
REP ^c c'
|
||||
REP f ph
|
||||
REP ph f
|
||||
REP c qu
|
||||
REP qu c
|
||||
REP k qu
|
||||
REP qu k
|
||||
REP x ct
|
||||
REP ct x
|
||||
REP bb b
|
||||
REP b bb
|
||||
REP cc c
|
||||
REP c cc
|
||||
REP ff f
|
||||
REP f ff
|
||||
REP ll l
|
||||
REP l ll
|
||||
REP mm m
|
||||
REP m mm
|
||||
REP nn n
|
||||
REP n nn
|
||||
REP pp p
|
||||
REP p pp
|
||||
REP rr r
|
||||
REP r rr
|
||||
REP ss s
|
||||
REP s ss
|
||||
REP ss c
|
||||
REP c ss
|
||||
REP ss ç
|
||||
REP ç ss
|
||||
REP tt t
|
||||
REP t tt
|
||||
REP œ oe
|
||||
REP oe œ
|
||||
REP æ ae
|
||||
REP ae æ
|
||||
REP ai é
|
||||
REP é ai
|
||||
REP ai è
|
||||
REP è ai
|
||||
REP ai ê
|
||||
REP ê ai
|
||||
REP ei é
|
||||
REP é ei
|
||||
REP ei è
|
||||
REP è ei
|
||||
REP ei ê
|
||||
REP ê ei
|
||||
REP o au
|
||||
REP au o
|
||||
REP o eau
|
||||
REP eau o
|
||||
REP ett èt
|
||||
REP èt ett
|
||||
REP ell èl
|
||||
REP èl ell
|
||||
REP t th
|
||||
REP th t
|
||||
REP ième$ e
|
||||
REP ème$ e
|
||||
REP è$ e
|
||||
REP mn$ min
|
||||
REP ogue$ ogiste
|
||||
REP ogiste$ ogue
|
||||
REP disez$ dites
|
||||
REP fesez$ faites
|
||||
REP faisez$ faites
|
||||
REP puit puits
|
||||
REP sanctionnable punissable
|
||||
REP questionnable discutable
|
||||
REP antitartre détartrant
|
||||
REP email courriel
|
||||
REP construirent construisirent
|
||||
KEY azertyuiop|qsdfghjklmù|wxcvbn|aéz|yèu|iço|oàp|aqz|zse|edr|rft|tgy|yhu|uji|iko|olpm|qws|sxd|dcf|fvg|gbh|hnj
|
||||
Binary file not shown.
@@ -20,7 +20,7 @@ https://git.libreoffice.org/dictionaries/+/refs/heads/master/pt_PT
|
||||
What Petal changed
|
||||
------------------
|
||||
|
||||
Nothing about which words are correct. `scripts/build_ptpt_dictionary.py`
|
||||
Nothing about which words are correct. `scripts/build_hunspell_dictionary.py`
|
||||
applies the upstream affix rules ahead of time — Hunspell's PFX/SFX expansion
|
||||
run once at build time instead of once per browser — and writes the resulting
|
||||
1,039,058 surface forms as a flat word list. The shipped `.aff` keeps only
|
||||
|
||||
@@ -61,6 +61,8 @@ describe('nativeLang', () => {
|
||||
expect(nativeLang()).toBe('zh-CN')
|
||||
setPackLang('pt-PT')
|
||||
expect(nativeLang()).toBe('pt-PT')
|
||||
setPackLang('fr')
|
||||
expect(nativeLang()).toBe('fr-FR')
|
||||
})
|
||||
|
||||
it('names a European Portuguese voice, never a Brazilian one', () => {
|
||||
@@ -73,4 +75,13 @@ describe('nativeLang', () => {
|
||||
speak('comum', nativeLang())
|
||||
expect(bodies[0]).toMatchObject({ text: 'comum', lang: 'pt-PT' })
|
||||
})
|
||||
|
||||
it('speaks the French reading of a collision in French', () => {
|
||||
// "chat" is the sharpest case in the fr pair: an English word, a French
|
||||
// word, and the companion's own animal. Nothing about the letters says
|
||||
// which — only the pack does.
|
||||
setPackLang('fr')
|
||||
speak('chat', nativeLang())
|
||||
expect(bodies.at(-1)).toMatchObject({ text: 'chat', lang: 'fr-FR' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +50,25 @@ describe('wordAt and the pair alphabet', () => {
|
||||
expect(wordAt(doc, posOf(6), true)?.word).toBe('today')
|
||||
})
|
||||
|
||||
it('keeps a curly apostrophe inside the word, because that is the only kind here', () => {
|
||||
// Typography.ts rewrites every ' typed in this editor into ’, so a token
|
||||
// that only matched the straight mark never saw an apostrophe at all.
|
||||
// "aujourd’hui" cut into "aujourd" + "hui" — neither one a French word, both
|
||||
// underlined, and withElision never reached.
|
||||
const doc = para('c’est aujourd’hui')
|
||||
expect(wordAt(doc, posOf(8), true)?.word).toBe('aujourd’hui')
|
||||
// English pays the same debt: "don’t" must stay whole to be looked up.
|
||||
expect(wordAt(doc, posOf(1))?.word).toBe('c’est')
|
||||
})
|
||||
|
||||
it('reads œ as a letter, not as a word boundary', () => {
|
||||
// U+0153 sits outside the Latin-1 ranges, so "cœur" tokenized as "c" + "ur"
|
||||
// and the orphan "ur" was long enough to be underlined. 586 œ forms ship in
|
||||
// the French word list and none of them were reachable.
|
||||
const doc = para('mon cœur')
|
||||
expect(wordAt(doc, posOf(5), true)?.word).toBe('cœur')
|
||||
})
|
||||
|
||||
it('stops the wide alphabet at the maths symbols hiding in Latin-1', () => {
|
||||
// × (U+00D7) and ÷ (U+00F7) sit inside the accented-letter block. A range
|
||||
// written À-ÿ would swallow them and glue "3×4" into one token.
|
||||
|
||||
@@ -32,8 +32,19 @@ interface PluginState {
|
||||
// writer with no Latin second language, adding accented letters can only find
|
||||
// new words to underline (the "café" and "naïve" she borrows), and finds no
|
||||
// mistakes she has actually made.
|
||||
const WORD_RE = /[A-Za-z][A-Za-z']*/g
|
||||
const WORD_RE_LATIN = /[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ']*/g
|
||||
//
|
||||
// Both apostrophes count as word characters, because Typography.ts rewrites
|
||||
// every ' typed in this editor into a curly ’ — so by the time text reaches the
|
||||
// tokenizer the straight mark is only ever seen in pasted content. Matching only
|
||||
// the straight one cut "aujourd’hui" into "aujourd" + "hui", neither of which is
|
||||
// a French word, and left the elision handling in useSpellChecker unreachable.
|
||||
// The dictionaries spell theirs straight; `combine` normalises on lookup.
|
||||
//
|
||||
// Œ/œ are named explicitly: they sit at U+0152/U+0153, outside the Latin-1
|
||||
// ranges below, so without them "cœur" tokenized as "c" + "ur" and the bare
|
||||
// "ur" was underlined. Æ/æ need no help — they are inside À-Ö and Ø-ö.
|
||||
const WORD_RE = /[A-Za-z][A-Za-z'’]*/g
|
||||
const WORD_RE_LATIN = /[A-Za-zÀ-ÖØ-öø-ÿŒœ][A-Za-zÀ-ÖØ-öø-ÿŒœ'’]*/g
|
||||
|
||||
// wordRe returns a fresh matcher for the alphabet in force. Fresh because these
|
||||
// are /g regexes carrying lastIndex, and two scans sharing one would interleave.
|
||||
@@ -55,8 +66,8 @@ function isCheckable(word: string): boolean {
|
||||
// lookup sees the bare token; returns the core plus how many chars were trimmed
|
||||
// off the front (to re-anchor the decoration).
|
||||
function coreOf(word: string): { core: string; lead: number } {
|
||||
const lead = word.match(/^'+/)?.[0].length ?? 0
|
||||
const trail = word.match(/'+$/)?.[0].length ?? 0
|
||||
const lead = word.match(/^['’]+/)?.[0].length ?? 0
|
||||
const trail = word.match(/['’]+$/)?.[0].length ?? 0
|
||||
return { core: word.slice(lead, word.length - trail), lead }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { combine, interleave, type Loaded } from './useSpellChecker'
|
||||
import { combine, interleave, withElision, type Dictionary, type Loaded } from './useSpellChecker'
|
||||
|
||||
// The both-dictionaries rule (SUGGESTIONS.md §3a), separated from the fetching
|
||||
// so it can be checked without a 15 MB word list. What matters here is not
|
||||
@@ -78,3 +78,61 @@ describe('correction pills', () => {
|
||||
expect(interleave([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// French elision (Phase 24). The word list is built without the elided forms
|
||||
// because carrying them costs 8.25 MB gzipped instead of 1.19 MB, so the split
|
||||
// happens here instead. What is being checked is not "does French work" but the
|
||||
// same question as everywhere else in this file: which way is it allowed to be
|
||||
// wrong.
|
||||
describe('elision', () => {
|
||||
const FR = ['l', 'd', 'j', 'qu', 'jusqu']
|
||||
const base: Dictionary = {
|
||||
correct: (w) => ['arbre', 'accord', 'elle', 'ici', "aujourd'hui", 'Étang'].includes(w),
|
||||
suggest: (w) => (w === 'arbrz' ? ['arbre', 'arbres'] : []),
|
||||
add: () => undefined,
|
||||
}
|
||||
const fr = withElision(base, FR)
|
||||
|
||||
it('looks past a clitic the language actually elides', () => {
|
||||
// The whole reason this exists: every one of these is absent from the built
|
||||
// word list, and every one of them is ordinary French.
|
||||
for (const w of ["l'arbre", "d'accord", "qu'elle", "jusqu'ici"]) {
|
||||
expect(base.correct(w), `${w} should not be in the list`).toBe(false)
|
||||
expect(fr.correct(w), w).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a word that carries its own apostrophe alone', () => {
|
||||
// "aujourd" is not a clitic, so this is never split — it matches directly,
|
||||
// which is why the build script keeps such stems verbatim.
|
||||
expect(fr.correct("aujourd'hui")).toBe(true)
|
||||
})
|
||||
|
||||
it('still flags an apostrophe that is not an elision', () => {
|
||||
// Both halves have to hold up: an unknown clitic on a real word, and a real
|
||||
// clitic on an unknown word. Splitting is a second lookup, not an amnesty.
|
||||
expect(fr.correct("zzz'arbre")).toBe(false)
|
||||
expect(fr.correct("l'zzzz")).toBe(false)
|
||||
})
|
||||
|
||||
it('is case-insensitive about the clitic, because a sentence can start with one', () => {
|
||||
expect(fr.correct("L'Étang")).toBe(true)
|
||||
})
|
||||
|
||||
it('puts the clitic back on its corrections', () => {
|
||||
// The pill replaces the whole token. Offering "arbre" for "l'arbrz" would
|
||||
// silently delete the article she wrote.
|
||||
expect(fr.suggest("l'arbrz")).toEqual(["l'arbre", "l'arbres"])
|
||||
})
|
||||
|
||||
it('does not split what has no apostrophe to split on', () => {
|
||||
expect(fr.correct("'arbre")).toBe(false) // nothing before the mark
|
||||
expect(fr.correct("l'")).toBe(false) // nothing after it
|
||||
})
|
||||
|
||||
it('hands back the same dictionary for a language that elides nothing', () => {
|
||||
// pt-PT and English pay nothing for this.
|
||||
expect(withElision(base, [])).toBe(base)
|
||||
expect(withElision(base, undefined)).toBe(base)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,9 @@ interface DictSpec {
|
||||
// compressed and is inflated here; en's 550 KB does not need it.
|
||||
gzipped?: boolean
|
||||
extendedAlphabet?: boolean
|
||||
// The little words this language glues onto the front of the next one. See
|
||||
// withElision — a language with none simply omits it.
|
||||
elision?: string[]
|
||||
}
|
||||
|
||||
const EN: DictSpec = {
|
||||
@@ -57,14 +60,28 @@ const EN: DictSpec = {
|
||||
dic: '/dictionaries/en/en.dic',
|
||||
}
|
||||
|
||||
// The thirteen words French elides onto whatever follows: le/la, de, je, me, te,
|
||||
// se, ce, ne, que, jusque, lorsque, puisque, quoique. Written as the *head* they
|
||||
// leave behind, because that is the half a token can be split on.
|
||||
//
|
||||
// Hunspell carries the elided forms as thirty-four prefix rules, which is seven
|
||||
// megabytes of gzip once expanded (measured: 8.25 MB against 1.19 MB without).
|
||||
// They are not new words — they are these thirteen glued to words already in the
|
||||
// list — so the build script skips them and withElision puts them back at
|
||||
// lookup. What is *not* here is deliberate: "aujourd'hui", "quelqu'un" and
|
||||
// "presqu'île" are entries in their own right and match directly, and "entr'" is
|
||||
// missing for the same reason Dicollecte omits it — modern French writes
|
||||
// "entraide".
|
||||
const FR_ELISION = ['l', 'd', 'j', 'm', 't', 's', 'c', 'n', 'qu', 'jusqu', 'lorsqu', 'puisqu', 'quoiqu']
|
||||
|
||||
// The writer's-language dictionary, by pair. zh has no Hunspell dictionary and
|
||||
// needs none: Chinese is not tokenized, so it is never flagged.
|
||||
//
|
||||
// pt-PT's word list is pre-expanded (see scripts/build_ptpt_dictionary.py) —
|
||||
// nspell expands affixes eagerly on load, and doing that to European
|
||||
// Portuguese's 1,340 rules in a browser wants over a gigabyte of heap. The
|
||||
// forms are computed at build time instead, so this is the same nspell reading
|
||||
// a bigger, simpler file.
|
||||
// Both Latin word lists are pre-expanded (see
|
||||
// scripts/build_hunspell_dictionary.py) — nspell expands affixes eagerly on
|
||||
// load, and doing that to European Portuguese's 1,340 rules or French's 5,600 in
|
||||
// a browser wants over a gigabyte of heap. The forms are computed at build time
|
||||
// instead, so this is the same nspell reading a bigger, simpler file.
|
||||
const PAIR_DICTS: Partial<Record<PairLang, DictSpec>> = {
|
||||
'pt-PT': {
|
||||
lang: 'pt-PT',
|
||||
@@ -73,6 +90,14 @@ const PAIR_DICTS: Partial<Record<PairLang, DictSpec>> = {
|
||||
gzipped: true,
|
||||
extendedAlphabet: true,
|
||||
},
|
||||
fr: {
|
||||
lang: 'fr',
|
||||
aff: '/dictionaries/fr/fr.aff',
|
||||
dic: '/dictionaries/fr/fr.dic.gz',
|
||||
gzipped: true,
|
||||
extendedAlphabet: true,
|
||||
elision: FR_ELISION,
|
||||
},
|
||||
}
|
||||
|
||||
// Where the list lived before it had an owner (Phase 7). Read once, handed to
|
||||
@@ -132,6 +157,54 @@ export interface Loaded {
|
||||
extendedAlphabet: boolean
|
||||
}
|
||||
|
||||
// withElision wraps a dictionary so that a clitic glued to the front of a word
|
||||
// is looked past rather than looked up.
|
||||
//
|
||||
// The tokenizer keeps internal apostrophes on purpose (don't, O'Brien), so
|
||||
// "l'arbre" reaches the dictionary whole. French writes that constantly and the
|
||||
// forms are not in the list — see FR_ELISION for why they are not — so without
|
||||
// this every elided article in a French document would be underlined, which is
|
||||
// the one failure mode this whole subsystem is built to avoid.
|
||||
//
|
||||
// It splits at the *first* apostrophe and only when the head is one of the
|
||||
// language's own clitics, and the remainder still has to be a word: "zzz'arbre"
|
||||
// is flagged because zzz is not a word French elides, and "l'zzzz" is flagged
|
||||
// because zzzz is not a word. Splitting buys a second lookup, not an amnesty.
|
||||
//
|
||||
// A language with no clitics gets the dictionary back untouched, so pt-PT and
|
||||
// English pay nothing for this.
|
||||
export function withElision(spell: Dictionary, clitics: string[] | undefined): Dictionary {
|
||||
if (!clitics || clitics.length === 0) return spell
|
||||
const heads = new Set(clitics)
|
||||
|
||||
// [clitic-with-apostrophe, remainder], or null if this isn't an elision.
|
||||
const split = (word: string): [string, string] | null => {
|
||||
const i = word.indexOf("'")
|
||||
if (i <= 0 || i === word.length - 1) return null
|
||||
if (!heads.has(word.slice(0, i).toLowerCase())) return null
|
||||
return [word.slice(0, i + 1), word.slice(i + 1)]
|
||||
}
|
||||
|
||||
return {
|
||||
correct: (word) => {
|
||||
if (spell.correct(word)) return true
|
||||
const parts = split(word)
|
||||
return parts ? spell.correct(parts[1]) : false
|
||||
},
|
||||
// Corrections come back with the clitic put back on, because the pill
|
||||
// replaces the whole token: offering "arbre" for "l'arbrz" would silently
|
||||
// delete the article she wrote.
|
||||
suggest: (word) => {
|
||||
const direct = spell.suggest(word)
|
||||
if (direct.length > 0) return direct
|
||||
const parts = split(word)
|
||||
if (!parts) return []
|
||||
return spell.suggest(parts[1]).map((s) => parts[0] + s)
|
||||
},
|
||||
add: (word) => spell.add(word),
|
||||
}
|
||||
}
|
||||
|
||||
// load builds one nspell instance and replays this writer's personal words into
|
||||
// it. `adoptLegacy` is passed only for English, and only on first load.
|
||||
async function load(spec: DictSpec, adoptLegacy: boolean): Promise<Loaded> {
|
||||
@@ -153,7 +226,11 @@ async function load(spec: DictSpec, adoptLegacy: boolean): Promise<Loaded> {
|
||||
: await api.listPersonalWords(spec.lang)
|
||||
for (const w of stored.words) spell.add(w)
|
||||
|
||||
return { lang: spec.lang, spell, extendedAlphabet: spec.extendedAlphabet ?? false }
|
||||
return {
|
||||
lang: spec.lang,
|
||||
spell: withElision(spell, spec.elision),
|
||||
extendedAlphabet: spec.extendedAlphabet ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
// interleave merges each dictionary's corrections round-robin. Concatenating
|
||||
@@ -175,6 +252,19 @@ export function interleave(lists: string[][]): string[] {
|
||||
return out
|
||||
}
|
||||
|
||||
// Typography.ts turns every apostrophe typed in the editor into a curly U+2019,
|
||||
// and every word list Petal ships spells its apostrophes straight ("aujourd'hui",
|
||||
// "don't", and the elision heads in FR_ELISION). Normalising here — the one place
|
||||
// every lookup passes through — keeps that a rendering detail rather than a
|
||||
// spelling one; corrections come back wearing whichever mark she actually used,
|
||||
// so accepting a pill never swaps her curly apostrophe for a straight one.
|
||||
const CURLY_APOSTROPHE = /’/g
|
||||
const STRAIGHT_APOSTROPHE = /'/g
|
||||
|
||||
function straighten(word: string): string {
|
||||
return word.replace(CURLY_APOSTROPHE, "'")
|
||||
}
|
||||
|
||||
// combine is the both-dictionaries rule itself, separated from the loading so it
|
||||
// can be reasoned about (and tested) on its own. `dicts` is a getter because the
|
||||
// writer's dictionary lands after English and the checker must see it when it
|
||||
@@ -189,9 +279,14 @@ export function combine(dicts: () => Loaded[]): SpellChecker {
|
||||
correct: (w) => {
|
||||
const loaded = dicts()
|
||||
if (loaded.length === 0) return true
|
||||
return loaded.some((d) => d.spell.correct(w))
|
||||
const word = straighten(w)
|
||||
return loaded.some((d) => d.spell.correct(word))
|
||||
},
|
||||
suggest: (w) => {
|
||||
const word = straighten(w)
|
||||
const out = interleave(dicts().map((d) => d.spell.suggest(word)))
|
||||
return word === w ? out : out.map((s) => s.replace(STRAIGHT_APOSTROPHE, '’'))
|
||||
},
|
||||
suggest: (w) => interleave(dicts().map((d) => d.spell.suggest(w))),
|
||||
// A getter, not a snapshot. The alphabet is read by every surface that
|
||||
// resolves a word, and if it were captured when the checker was built it
|
||||
// would still say A-Z after her dictionary arrived — so a pt-PT writer's
|
||||
|
||||
+83
-10
@@ -3,12 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { onPackChange, pack, resetPackForTests, setPackLang, shippedPacks } from './index'
|
||||
import { zh } from './packs/zh'
|
||||
import { ptPT } from './packs/pt-PT'
|
||||
import { fr } from './packs/fr'
|
||||
import type { Pack } from './types'
|
||||
|
||||
// Every pack that ships. Shape assertions run over all of them, because the
|
||||
// point of Phase 19 was that a language is data — and data that only the first
|
||||
// author's pack satisfies isn't a shape, it's a coincidence.
|
||||
const PACKS: Pack[] = [zh, ptPT]
|
||||
const PACKS: Pack[] = [zh, ptPT, fr]
|
||||
|
||||
beforeEach(() => {
|
||||
resetPackForTests()
|
||||
@@ -33,8 +34,9 @@ describe('pack selection', () => {
|
||||
|
||||
it('falls back rather than blanking on a pair with no pack yet', () => {
|
||||
// A pair_lang the deployment has no copy for is a deployment that got ahead
|
||||
// of its translation. She should still get a working editor.
|
||||
setPackLang('fr')
|
||||
// of its translation. She should still get a working editor. (This was 'fr'
|
||||
// until Phase 24 gave fr a pack; 'es' is the pair still waiting for one.)
|
||||
setPackLang('es')
|
||||
expect(pack()).toBe(zh)
|
||||
setPackLang('klingon')
|
||||
expect(pack()).toBe(zh)
|
||||
@@ -54,7 +56,7 @@ describe('pack selection', () => {
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
|
||||
// An unshipped pair resolves back to zh, which is also not a change.
|
||||
setPackLang('fr')
|
||||
setPackLang('es')
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
|
||||
setPackLang('pt-PT')
|
||||
@@ -67,7 +69,7 @@ describe('pack selection', () => {
|
||||
// matching allowlist exists to enforce from the other side.
|
||||
it('offers exactly the pairs it has copy for', () => {
|
||||
const codes = shippedPacks().map((p) => p.code)
|
||||
expect(codes.sort()).toEqual(['pt-PT', 'zh'])
|
||||
expect(codes.sort()).toEqual(['fr', 'pt-PT', 'zh'])
|
||||
// Every offered pair names itself, because a writer stranded on the wrong
|
||||
// pack can only read the label that is in her own language.
|
||||
for (const p of shippedPacks()) expect(p.nativeName.length).toBeGreaterThan(0)
|
||||
@@ -192,13 +194,27 @@ describe('the pt-PT pack', () => {
|
||||
// is invisible to anyone who doesn't read Portuguese — including whoever
|
||||
// reviews this diff.
|
||||
it('is European Portuguese, not Brazilian', () => {
|
||||
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v))
|
||||
// Lowercased: half this copy is sentences, and a form that only ever appears
|
||||
// at the start of one ("Actualmente…") would otherwise walk straight past
|
||||
// both greps below.
|
||||
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v)).toLowerCase()
|
||||
|
||||
// Brazilian spellings and vocabulary that would give the pack away.
|
||||
for (const bad of ['sinônimo', 'acadêmico', 'arquivo', 'tela', 'salvar', 'deletar', 'usuário', 'você']) {
|
||||
expect(text, `pt-BR form "${bad}" in the pt-PT pack`).not.toContain(bad)
|
||||
}
|
||||
|
||||
// And pre-Acordo spellings, which this grep did NOT cover until Phase 24's
|
||||
// review pass found *adjectivos*, *actualmente* and *decepção* sitting in a
|
||||
// file whose own header commits to post-Acordo. Guarding the pt-BR fault
|
||||
// line while leaving the pack's stated spelling policy unchecked is half a
|
||||
// test: both are invisible to a reviewer who doesn't read Portuguese.
|
||||
// Whole words only — the English "actually" and "factual" contain "actual".
|
||||
for (const bad of ['actualmente', 'adjectivo', 'decepção', 'acção', 'óptimo',
|
||||
'recepção', 'objectivo', 'directamente', 'exacto']) {
|
||||
expect(text, `pre-Acordo form "${bad}" in the pt-PT pack`).not.toContain(bad)
|
||||
}
|
||||
|
||||
// And the European forms that should be there instead.
|
||||
expect(ptPT.editor.synonyms).toContain('Sinónimos')
|
||||
expect(ptPT.styles.academic.native).toBe('Académico')
|
||||
@@ -222,15 +238,17 @@ describe('the pt-PT pack', () => {
|
||||
// is the one most easily spoiled by a stray comparison. The rule is enforced
|
||||
// in SQL on the backend; here it is enforced in the copy.
|
||||
it('keeps the journal to growth and to her own past self', () => {
|
||||
const text = JSON.stringify({ zh: zh.journal, pt: ptPT.journal }, (_k, v) =>
|
||||
typeof v === 'function' ? JSON.stringify(v(2, 3)) : v,
|
||||
const text = JSON.stringify(
|
||||
PACKS.map((p) => p.journal),
|
||||
(_k, v) => (typeof v === 'function' ? JSON.stringify(v(2, 3)) : v),
|
||||
)
|
||||
for (const bad of ['error', 'mistake', 'wrong', 'streak', 'average', 'erro', 'errada', '错误']) {
|
||||
for (const bad of ['error', 'mistake', 'wrong', 'streak', 'average', 'erro', 'errada',
|
||||
'erreur', 'faute', 'moyenne', '错误']) {
|
||||
expect(text.toLowerCase(), `the journal must not talk about "${bad}"`).not.toContain(bad)
|
||||
}
|
||||
})
|
||||
|
||||
it('says the collision line the zh pair never needed', () => {
|
||||
it('says the collision line the zh pair never needed (pt-PT)', () => {
|
||||
// "sale", "comum" and "tarde" are words on both sides of this pair, so the
|
||||
// word card's second reading is reachable copy here — unlike in zh.
|
||||
expect(ptPT.editor.alsoIn).toBeTruthy()
|
||||
@@ -238,6 +256,51 @@ describe('the pt-PT pack', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the fr pack', () => {
|
||||
// French's regional question is not the dictionary's — Debian's fr_FR, fr_CA
|
||||
// and fr_BE are all symlinks to one word list — so the whole of it lives in
|
||||
// this file, which makes it exactly as invisible to a reviewer as the pt-BR
|
||||
// forms were, and worth pinning the same way.
|
||||
it('is metropolitan French, not Québécois', () => {
|
||||
const text = JSON.stringify(fr, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v)).toLowerCase()
|
||||
for (const bad of ['courriel', 'clavarder', 'magasiner', 'fin de semaine', 'baladodiffusion']) {
|
||||
expect(text, `Québécois form "${bad}" in the fr pack`).not.toContain(bad)
|
||||
}
|
||||
// And the voice it is read in, for the same reason the locale is pinned.
|
||||
expect(fr.locale).toBe('fr-FR')
|
||||
})
|
||||
|
||||
// The pack punctuates the way French does, which is the same habit
|
||||
// prose.spaceBeforePunct tells her not to carry into her English. Both halves
|
||||
// of that are deliberate and either would look like a typo to a tidier.
|
||||
it('keeps French spacing and guillemets in its own copy', () => {
|
||||
expect(fr.companion.greeting.native).toContain(' !')
|
||||
expect(fr.prose.articleAn('apple')).toContain('« an apple »')
|
||||
expect(fr.prose.spaceBeforePunct).toContain('inverse du français')
|
||||
})
|
||||
|
||||
it('renders its interpolated lines with the value in place', () => {
|
||||
expect(fr.app.duplicateTitle('Printemps')).toBe('Printemps (copie)')
|
||||
expect(fr.companion.milestone(300).native).toContain('300 mots')
|
||||
// French agreement is the pack's business, the same way English
|
||||
// pluralisation is — the call site only ever passes a number.
|
||||
expect(fr.garden.reviewDue(1)).toContain('1 mot ·')
|
||||
expect(fr.garden.reviewDue(4)).toContain('4 mots ·')
|
||||
expect(fr.garden.growing(1)).toContain('1 fleur au jardin')
|
||||
expect(fr.garden.growing(3)).toContain('3 fleurs au jardin')
|
||||
expect(fr.journal.kept(1)).toContain('1 chose que tu as retenue')
|
||||
expect(fr.journal.kept(5)).toContain('5 choses que tu as retenues')
|
||||
})
|
||||
|
||||
it('says the collision line, which this pair needs most of all', () => {
|
||||
// English took so much from French that the collisions are the rule rather
|
||||
// than the exception: chat, pain, coin, sale, four, car, or.
|
||||
expect(fr.editor.alsoIn).toBeTruthy()
|
||||
expect(fr.editor.alsoIn).not.toBe(zh.editor.alsoIn)
|
||||
expect(fr.editor.alsoIn).not.toBe(ptPT.editor.alsoIn)
|
||||
})
|
||||
})
|
||||
|
||||
// False friends are a per-pair dataset rather than copy: the Latin pairs carry
|
||||
// the traps their writers actually fall into, and the zh pair legitimately has
|
||||
// none. Both halves of that are worth pinning.
|
||||
@@ -255,6 +318,16 @@ describe('false friends', () => {
|
||||
expect(Object.keys(ptPT.falseFriends).length).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('the fr pair carries the ones that cost most', () => {
|
||||
// "attend" and "pass" are the two this pair has and pt-PT does not: French
|
||||
// *attendre* is to wait, and *passer un examen* is to sit one, not to pass
|
||||
// it — which is the false friend most likely to end up in a real letter.
|
||||
for (const word of ['actually', 'library', 'attend', 'pass', 'sensible']) {
|
||||
expect(fr.falseFriends[word], word).toBeDefined()
|
||||
}
|
||||
expect(Object.keys(fr.falseFriends).length).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('is keyed by the lowercase English word, so a lookup can find it', () => {
|
||||
for (const p of PACKS) {
|
||||
for (const key of Object.keys(p.falseFriends)) {
|
||||
|
||||
@@ -17,12 +17,13 @@ import { useSyncExternalStore } from 'react'
|
||||
import type { Pack, PairLang } from './types'
|
||||
import { zh } from './packs/zh'
|
||||
import { ptPT } from './packs/pt-PT'
|
||||
import { fr } from './packs/fr'
|
||||
|
||||
export type { Pack, PairLang, Line } from './types'
|
||||
|
||||
// Every pack Petal ships. fr and es are the same two lines apiece when their
|
||||
// copy is written — TypeScript names every string a new pack still owes.
|
||||
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT }
|
||||
// Every pack Petal ships. es is the same two lines when its copy is written —
|
||||
// TypeScript names every string a new pack still owes.
|
||||
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT, fr }
|
||||
|
||||
const DEFAULT_LANG: PairLang = 'zh'
|
||||
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
// The French pack — the third pair, and the first written against a groove
|
||||
// rather than cutting one.
|
||||
//
|
||||
// ⚠️ REVIEWED BY FOUR MODELS, NOT BY A NATIVE SPEAKER.
|
||||
// SUGGESTIONS.md §3 sets the bar: a pack should be reviewed by someone who
|
||||
// speaks the pair before it is trusted — the same standard the zh copy got by
|
||||
// being written for a real reader. That has still not happened. What has
|
||||
// happened (2026-07-27) is an interim pass: four different models read this file
|
||||
// independently as French speakers, and only findings at least two of them
|
||||
// reached on their own were applied — five of them, listed in BUILD_PLAN Phase
|
||||
// 24. A quorum of models agreeing is agreement, not authority: it caught a
|
||||
// "clique droit" that is not French and an adjective disagreeing with "on", and
|
||||
// it would not catch a line that is correct and lifeless. Treat this as a
|
||||
// better-checked draft, and still expect a speaker to change the register long
|
||||
// before the vocabulary.
|
||||
//
|
||||
// The choices this file makes, and why:
|
||||
//
|
||||
// * **Metropolitan French, not Québécois.** Unlike pt, the *dictionary* poses
|
||||
// no regional question — Debian's fr_FR, fr_CA, fr_BE, fr_CH and fr_LU are
|
||||
// all symlinks to one file — so the whole regional decision lives here in
|
||||
// the copy: e-mail rather than courriel, tchatter rather than clavarder,
|
||||
// week-end rather than fin de semaine. The Piper voice is fr_FR for the same
|
||||
// reason. This is a choice, not a verdict; a Québécoise writer would want
|
||||
// her own pack and should get one rather than a patched version of this.
|
||||
// * **Tutoiement.** Petal is a companion in someone's private notebook, and
|
||||
// *vous* would put a desk between them. Same call the pt-PT pack made about
|
||||
// *tu* over *você*, for the same reason.
|
||||
// * **"Se connecter" / "Se déconnecter"**, not "login" / "logout".
|
||||
// * **French spacing and « guillemets », on purpose.** French puts a space
|
||||
// before : ; ! ? and inside « », and this file does too — which is exactly
|
||||
// the habit `prose.spaceBeforePunct` exists to warn her about in her
|
||||
// *English*. The pack demonstrates, in its own punctuation, the rule its own
|
||||
// prose note tells her not to carry across. It is an ordinary space rather
|
||||
// than the typographically correct U+202F: a narrow no-break space is
|
||||
// invisible in a diff, indistinguishable from a plain one in an editor, and
|
||||
// the next pack author would strip it by accident. The cost is that a line
|
||||
// may wrap before the « ! », which is a smaller problem than copy nobody can
|
||||
// safely edit.
|
||||
// * The 1990 spelling reform is left alone: this copy uses the traditional
|
||||
// forms, and her dictionary accepts both (see build_hunspell_dictionary.py).
|
||||
//
|
||||
// French first, English underneath — same shape as the other packs, for the same
|
||||
// reason: she reads her own language faster, and the English half is what she is
|
||||
// here to learn.
|
||||
|
||||
import type { Pack } from '../types'
|
||||
|
||||
export const fr: Pack = {
|
||||
code: 'fr',
|
||||
nativeName: 'Français',
|
||||
locale: 'fr-FR',
|
||||
|
||||
app: {
|
||||
duplicateTitle: (title) => `${title} (copie)`,
|
||||
garden: 'Jardin de mots',
|
||||
history: 'Historique',
|
||||
},
|
||||
|
||||
auth: {
|
||||
title: 'Reconnecte-toi',
|
||||
titleEn: 'Please sign in again',
|
||||
bodyWithDraft:
|
||||
'Ce que tu viens d’écrire est gardé sur cet appareil — reconnecte-toi et tout s’enregistrera tout seul.',
|
||||
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
|
||||
bodyPlain: 'Ta session a expiré. Tout ce que tu as écrit est déjà enregistré.',
|
||||
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
|
||||
signIn: 'Se connecter · Sign in',
|
||||
},
|
||||
|
||||
companion: {
|
||||
choose: 'Choisis un compagnon · Choose a companion',
|
||||
|
||||
encouragements: [
|
||||
{ native: 'Voilà ! Cette phrase coule beaucoup mieux 🌸', en: 'Lovely — that reads so much smoother now.' },
|
||||
{ native: 'Tu écris de mieux en mieux ✨', en: "You're getting better and better." },
|
||||
{ native: 'J’aime beaucoup ce changement 💕', en: 'I really like that change.' },
|
||||
{ native: 'Continue comme ça — tu y arrives !', en: 'Keep going — you’ve got this!' },
|
||||
{ native: 'Mmh, c’est bien plus clair comme ça 👍', en: 'Mm, that’s much clearer.' },
|
||||
{ native: 'Quel joli choix de mot 🌷', en: 'That’s such a good word choice.' },
|
||||
{ native: 'Oh, ce paragraphe se lit tout seul ☁️', en: 'Ooh, that paragraph flows so nicely.' },
|
||||
{ native: 'J’adore te voir écrire avec plus d’assurance 💛', en: 'I love watching you write with more confidence.' },
|
||||
{ native: 'Chaque petit progrès compte 🌱', en: 'Every little bit of progress counts.' },
|
||||
{ native: 'Tes mots brillent aujourd’hui ✨', en: 'Your words are sparkling today.' },
|
||||
],
|
||||
|
||||
tips: [
|
||||
{ native: 'Astuce : en anglais, les phrases courtes se lisent mieux.', en: 'Tip: shorter English sentences often read clearer.' },
|
||||
{ native: 'N’oublie pas les articles « the » et « a ».', en: "Don't forget articles like “the” and “a”." },
|
||||
{ native: 'Pour le passé, utilise le prétérit : go → went.', en: 'For the past, use past tense: go → went.' },
|
||||
{ native: 'Lire à voix haute aide à repérer ce qui sonne bizarre.', en: 'Reading aloud helps you catch awkward spots.' },
|
||||
{ native: 'Une idée par paragraphe, et tout devient limpide.', en: 'One idea per paragraph keeps it tidy.' },
|
||||
{ native: 'Un doute ? Demande-moi ✨', en: 'Not sure about something? Just ask me. ✨' },
|
||||
{ native: 'Le pluriel prend un « s » : two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
|
||||
// Two tips the zh pack has no use for: French and English share enough
|
||||
// vocabulary to make both the traps below daily hazards.
|
||||
{ native: 'Attention aux faux amis : « actually » ne veut pas dire *actuellement*.', en: 'Careful with false friends — “actually” means *in fact*.' },
|
||||
{ native: 'En anglais, pas d’espace avant « ! » ni « ? » — c’est une habitude française.', en: 'English puts no space before “!” or “?” — that space is a French habit.' },
|
||||
],
|
||||
|
||||
breaks: [
|
||||
{ native: 'Tu écris depuis un moment — étire-toi et repose tes yeux 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
|
||||
{ native: 'Un verre d’eau et cinq minutes de pause ?', en: 'Sip some water and take five?' },
|
||||
{ native: 'Regarde au loin un instant, ça soulage les yeux 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
|
||||
],
|
||||
|
||||
// Late-night nudges. The English wit is the user's own and is kept word for
|
||||
// word across every pack; the French line leads gently into it, exactly as
|
||||
// the Mandarin and Portuguese ones do.
|
||||
bedtime: [
|
||||
{ native: 'Ton lit doit se demander où tu es 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||
{ native: 'Quand tu es fatiguée, tu écris mal — va te reposer 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||
{ native: 'La nuit porte conseil ✨', en: 'Sleep is a wondrous enabler.' },
|
||||
{ native: 'Tu entends ? Non… parce que tout le monde dort, et toi aussi tu devrais 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
// French proverbs on sleep and haste, in place of the Portuguese and
|
||||
// Chinese ones — a pack is not a translation of another pack.
|
||||
{ native: 'Qui dort dîne.', en: 'Sleep is its own kind of supper.' },
|
||||
{ native: 'L’avenir appartient à ceux qui se lèvent tôt.', en: 'The future belongs to those who get up early.' },
|
||||
{ native: 'Rien ne sert de courir, il faut partir à point.', en: 'No use running; you have to set off in good time.' },
|
||||
],
|
||||
|
||||
greeting: { native: 'Coucou ! Je te tiens compagnie 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
|
||||
welcomeBack: { native: 'Te revoilà ✨ on continue !', en: 'Welcome back ✨ let’s keep going!' },
|
||||
|
||||
errors: [
|
||||
{ native: 'Oups — un petit accroc, mais tes mots sont en sécurité.', en: 'Oops — a little hiccup, but your words are safe.' },
|
||||
{ native: 'Zut, je me suis emmêlé les pinceaux une seconde — je reviens.', en: 'Haiya, I got stuck for a sec — back in a moment.' },
|
||||
{ native: 'Ne t’en fais pas, on réessaie dans un instant 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
|
||||
],
|
||||
|
||||
milestone: (words: number) => ({
|
||||
native: `Ouah ! Déjà ${words} mots 🎉`,
|
||||
en: `Wow — ${words} words already! Amazing. 🎉`,
|
||||
}),
|
||||
|
||||
// Une petite chose à écrire, proposée une fois par jour à une page blanche.
|
||||
// Des souvenirs et des avis, jamais des exercices : il n’y a rien ici qu’on
|
||||
// puisse rater, et c’est précisément l’intention.
|
||||
invitations: [
|
||||
{ native: 'Écris 50 mots : une petite chose qui t’a fait sourire aujourd’hui 🌸', en: 'Write 50 words: one small thing that made you smile today.' },
|
||||
{ native: 'Écris 50 mots : la meilleure chose que tu as mangée aujourd’hui', en: 'Write 50 words: the best thing you ate today.' },
|
||||
{ native: 'Écris 50 mots : ce que tu vois par ta fenêtre en ce moment', en: 'Write 50 words: what you can see out of your window right now.' },
|
||||
{ native: 'Écris 50 mots : un endroit où tu retournerais volontiers', en: 'Write 50 words: somewhere you would happily go back to.' },
|
||||
{ native: 'Écris 50 mots : une chose que tu as apprise cette semaine', en: 'Write 50 words: one thing you learned this week.' },
|
||||
{ native: 'Écris 50 mots : un message à toi-même pour dans un an', en: 'Write 50 words: something to tell yourself a year from now.' },
|
||||
{ native: 'Écris 50 mots : une chanson que tu écoutes en boucle en ce moment', en: 'Write 50 words: a song you have had on lately.' },
|
||||
{ native: 'Écris 50 mots : quelqu’un que tu aimerais remercier aujourd’hui', en: 'Write 50 words: someone you would like to thank today.' },
|
||||
],
|
||||
inviteAccept: 'C’est parti · Let’s write',
|
||||
inviteDecline: 'Pas aujourd’hui · Not today',
|
||||
declined: { native: 'D’accord, je retourne dormir 😴', en: 'Fair enough — back to my nap. 😴' },
|
||||
|
||||
names: {
|
||||
cat: 'Chat dormeur',
|
||||
dog: 'Chien joyeux',
|
||||
'wiggle-dog': 'Chien frétillant',
|
||||
butterfly: 'Papillon',
|
||||
parrot: 'Perroquet',
|
||||
},
|
||||
},
|
||||
|
||||
prose: {
|
||||
longSentence: 'Cette phrase est un peu longue — la couper en deux ou trois la rendra plus claire 🌸',
|
||||
commaSplice: 'Ici, deux phrases sont reliées par une simple virgule. Mets un point, ou relie-les avec « and / but ».',
|
||||
vagueThis: (word) => `On ne sait pas bien à quoi « ${word} » renvoie — précise-le (par exemple « ${word} idea / change… »).`,
|
||||
oxfordComma: 'Dans une énumération de trois éléments ou plus, une virgule avant « and / or » aide à lire (la virgule d’Oxford).',
|
||||
transitionComma: (word) => `Après un mot de liaison en début de phrase, mets une virgule : « ${word}, … ».`,
|
||||
capitalizeSentence: 'Commence chaque phrase par une majuscule.',
|
||||
repeatedWord: (word) => `« ${word} » semble écrit deux fois — jette un œil.`,
|
||||
capitalizeI: 'En anglais, le « I » (je) prend toujours une majuscule.',
|
||||
// The rule this pack's own punctuation illustrates from the other side.
|
||||
spaceBeforePunct: 'En anglais, pas d’espace avant la ponctuation : la virgule et le point se collent au mot. C’est l’inverse du français.',
|
||||
spaceAfterPunct: 'Après une virgule ou un point, laisse un espace avant le mot suivant.',
|
||||
articleAn: (word) => `Devant un son de voyelle, on met « an » : « an ${word} ».`,
|
||||
articleA: (word) => `Devant un son de consonne, on met « a » : « a ${word} ».`,
|
||||
uncountable: (word, singular) => `« ${word} » est indénombrable en anglais — pas de s, « ${singular} » suffit.`,
|
||||
capitalizeProper: (fixed) => `En anglais, les langues, les nationalités, les jours et les mois prennent une majuscule : « ${fixed} ».`,
|
||||
thirdPersonS: (subject, verb) => `Avec he/she/it, le verbe prend un -s : « ${subject} ${verb} ».`,
|
||||
pluralAfter: (determiner, noun) => `Après « ${determiner} », le nom se met au pluriel : « ${determiner} ${noun}s ».`,
|
||||
doubleDeterminer: (first, second) => `« ${first} ${second} » contient deux déterminants — n’en garde qu’un (enlève « ${first} », par exemple).`,
|
||||
thereArePlural: (noun) => `Au pluriel, on dit « there are » : « there are ${noun}… ».`,
|
||||
itsOwn: '« it’s » = « it is ». Pour dire « son / sa », c’est « its » — donc « its own ».',
|
||||
itsIs: (rest) => `Ici c’est « it’s ${rest} » (it is) ; « its » est le possessif.`,
|
||||
thanNotThen: (word) => `Dans une comparaison, on écrit « than », pas « then » : « ${word} than ».`,
|
||||
preposition: (wrong, right) => `En anglais on dit « ${right} », pas « ${wrong} » — cette préposition est figée.`,
|
||||
collocation: (wrong, right) => `En anglais, ces mots vont ensemble comme ceci : « ${right} », et non « ${wrong} ».`,
|
||||
doubleComparative: (lead, word) => `« ${word} » est déjà le comparatif — pas besoin de « ${lead} » : « ${word} » suffit.`,
|
||||
peopleArePlural: (verb) => `« People » est pluriel en anglais : « people ${verb} ».`,
|
||||
ageIsNotHave: (years) => `En anglais, l’âge se dit avec *to be*, pas avec *avoir* : « I am ${years} years old ».`,
|
||||
agreeIsAVerb: '« Agree » est déjà le verbe — pas de *to be* devant : on dit « I agree ».',
|
||||
forNotSince: (duration) => `Pour une durée, on utilise « for » : « for ${duration} ». « Since » marque le point de départ (since 2020).`,
|
||||
veryBeforeVerb: (verb) => `« Very » n’accompagne que les adjectifs, pas les verbes : « really ${verb} », ou « ${verb} … very much ».`,
|
||||
turnOnNotOpen: (thing, on) => `En anglais, on n’ouvre pas un appareil, on l’allume : « turn ${on ? 'on' : 'off'} the ${thing} ».`,
|
||||
althoughOrBut: (word) => `En anglais, on met « ${word} » ou « but », jamais les deux dans la même phrase.`,
|
||||
},
|
||||
|
||||
// Les faux amis entre le français et l’anglais — le piège qui donne le
|
||||
// sentiment d’être ridicule plutôt que simplement corrigée. D’où le simple
|
||||
// avertissement : Petal ne remplace jamais le mot, parce que « actually »
|
||||
// était peut-être bien celui qu’elle voulait.
|
||||
falseFriends: {
|
||||
actually: {
|
||||
native: '« Actually » veut dire *en fait*, pas *actuellement*. Pour « actuellement », on dit « currently » ou « nowadays ».',
|
||||
en: '“Actually” means *in fact*. For the French *actuellement*, English uses “currently”.',
|
||||
},
|
||||
eventually: {
|
||||
native: '« Eventually » veut dire *finalement, tôt ou tard* — pas *éventuellement*. Pour cela : « possibly » ou « if necessary ».',
|
||||
en: '“Eventually” means *in the end*, not *possibly*.',
|
||||
},
|
||||
library: {
|
||||
native: '« Library » est la *bibliothèque*. La *librairie* se dit « bookshop » / « bookstore ».',
|
||||
en: '“Library” is where books are lent; a shop that sells them is a “bookshop”.',
|
||||
},
|
||||
sensible: {
|
||||
native: '« Sensible » veut dire *raisonnable*. Pour *sensible*, on dit « sensitive ».',
|
||||
en: '“Sensible” means level-headed; the French *sensible* is “sensitive”.',
|
||||
},
|
||||
assist: {
|
||||
native: '« Assist » veut dire *aider*. Pour *assister à* (être présent), on dit « attend ».',
|
||||
en: '“Assist” means to help; *assister à* is “attend”.',
|
||||
},
|
||||
attend: {
|
||||
native: '« Attend » veut dire *assister à*. Pour *attendre*, on dit « wait ».',
|
||||
en: '“Attend” means to go to something; *attendre* is “to wait”.',
|
||||
},
|
||||
pretend: {
|
||||
native: '« Pretend » veut dire *faire semblant*. Pour *prétendre* (affirmer), on dit « claim ».',
|
||||
en: '“Pretend” means to fake something; *prétendre* is “to claim”.',
|
||||
},
|
||||
deception: {
|
||||
native: '« Deception » veut dire *tromperie*. La *déception* se dit « disappointment ».',
|
||||
en: '“Deception” means being misled; *déception* is “disappointment”.',
|
||||
},
|
||||
delay: {
|
||||
native: '« Delay » veut dire *retard*. Un *délai* se dit « deadline » ou « time limit ».',
|
||||
en: '“Delay” is lateness; a French *délai* is a “deadline”.',
|
||||
},
|
||||
achieve: {
|
||||
native: '« Achieve » veut dire *réussir, accomplir*. Pour *achever* (terminer), on dit « finish » ou « complete ».',
|
||||
en: '“Achieve” means to accomplish; *achever* is “to finish”.',
|
||||
},
|
||||
rest: {
|
||||
native: '« Rest » veut dire *se reposer* (ou *le reste*). Pour *rester*, on dit « stay ».',
|
||||
en: '“Rest” is to relax; *rester* is “to stay”.',
|
||||
},
|
||||
journey: {
|
||||
native: '« Journey » est un *voyage*. Une *journée* se dit « day ».',
|
||||
en: '“Journey” is a trip; *journée* is a “day”.',
|
||||
},
|
||||
location: {
|
||||
native: '« Location » est un *endroit*. La *location* (louer) se dit « rental » ou « renting ».',
|
||||
en: '“Location” is a place; the French *location* is a “rental”.',
|
||||
},
|
||||
travel: {
|
||||
native: '« Travel » veut dire *voyager*. Pour *travailler*, on dit « work ».',
|
||||
en: '“Travel” means to journey; *travailler* is “to work”.',
|
||||
},
|
||||
pass: {
|
||||
native: '« Pass an exam » veut dire *réussir* un examen. Pour *passer* un examen, on dit « take an exam ».',
|
||||
en: '“Pass an exam” means you succeeded; *passer un examen* is “to take” one.',
|
||||
},
|
||||
resume: {
|
||||
native: '« Resume » veut dire *reprendre*. Un *résumé* se dit « summary » (aux États-Unis, « résumé » est un CV).',
|
||||
en: '“Resume” means to start again; a *résumé* is a “summary”.',
|
||||
},
|
||||
college: {
|
||||
native: '« College » est l’enseignement supérieur. Le *collège* français se dit « middle school ».',
|
||||
en: '“College” is higher education; a French *collège* is “middle school”.',
|
||||
},
|
||||
envy: {
|
||||
native: '« Envy » est la *jalousie*. Pour *avoir envie de*, on dit « to feel like » ou « to want ».',
|
||||
en: '“Envy” is jealousy; *avoir envie de* is “to feel like”.',
|
||||
},
|
||||
crayon: {
|
||||
native: '« Crayon » est un *crayon de couleur* (en cire). Le *crayon à papier* se dit « pencil ».',
|
||||
en: '“Crayon” is a wax colouring stick; a French *crayon* is a “pencil”.',
|
||||
},
|
||||
coin: {
|
||||
native: '« Coin » est une *pièce de monnaie*. Le *coin* (angle) se dit « corner ».',
|
||||
en: '“Coin” is money; the French *coin* is a “corner”.',
|
||||
},
|
||||
figure: {
|
||||
native: '« Figure » est un *chiffre* ou une *silhouette*. La *figure* (visage) se dit « face ».',
|
||||
en: '“Figure” is a number or a shape; the French *figure* is a “face”.',
|
||||
},
|
||||
comprehensive: {
|
||||
native: '« Comprehensive » veut dire *complet, exhaustif*. Pour *compréhensif* (indulgent), on dit « understanding ».',
|
||||
en: '“Comprehensive” means thorough; *compréhensif* is “understanding”.',
|
||||
},
|
||||
},
|
||||
|
||||
docs: {
|
||||
sortRecent: 'Récents · Recent',
|
||||
sortTitle: 'Titre · Title',
|
||||
sortLongest: 'Les plus longs · Longest',
|
||||
backUpAll: 'Tout sauvegarder · Back up all :',
|
||||
signOut: 'Se déconnecter · Sign out',
|
||||
duplicate: 'Dupliquer · Duplicate',
|
||||
searchPlaceholder: 'Rechercher · Search',
|
||||
searching: 'Recherche… · Searching…',
|
||||
noMatches: 'Aucun résultat · No matches',
|
||||
tags: 'Étiquettes · Tags',
|
||||
newTagPlaceholder: 'Nouvelle étiquette · New tag',
|
||||
language: 'Langue · Language',
|
||||
languageFailed: 'Le changement n’a pas abouti — la langue n’a pas bougé · Couldn’t switch',
|
||||
},
|
||||
|
||||
editor: {
|
||||
askPlaceholder: 'Ask why… / Demande pourquoi…',
|
||||
findPlaceholder: 'Rechercher · Find',
|
||||
findNone: 'Rien · 0',
|
||||
matchCase: 'Match case · Respecter la casse',
|
||||
close: 'Close · Fermer',
|
||||
replacePlaceholder: 'Remplacer par · Replace',
|
||||
replace: 'Remplacer',
|
||||
replaceAll: 'Tout',
|
||||
spelling: 'Orthographe · Spelling',
|
||||
noSuggestions: 'Aucune suggestion · No suggestions',
|
||||
addToDictionary: 'Ajouter au dictionnaire · Add to dictionary',
|
||||
readSelection: 'Lire la sélection à voix haute · Read selection aloud',
|
||||
rewrite: 'Réécrire · Rewrite',
|
||||
rewriting: 'Réécriture… · Rewriting…',
|
||||
rewriteFailed: 'La réécriture n’a pas abouti — réessaie · Couldn’t rewrite',
|
||||
cancel: 'Annuler · Cancel',
|
||||
retry: 'Réessayer · Retry',
|
||||
useThis: 'Utiliser celle-ci · Use this',
|
||||
word: 'Mot · Word',
|
||||
inGarden: 'Déjà dans le jardin · In your garden (tap to remove)',
|
||||
saveToGarden: 'Garder dans le jardin · Save to garden',
|
||||
readAloud: 'Lire à voix haute · Read aloud',
|
||||
readSlowly: 'Lire lentement · Read slowly',
|
||||
readAloudNative: 'Lire en français · Read in French',
|
||||
lookingUp: 'Recherche… · Looking up…',
|
||||
definition: 'Définition · Definition',
|
||||
synonyms: 'Synonymes · Synonyms',
|
||||
tapToSwap: 'appuie pour changer · tap to swap',
|
||||
nothingFound: 'Je n’ai pas trouvé ce mot · Nothing found for this word',
|
||||
origin: 'Origine · Origin',
|
||||
// The French pair sees this constantly — English took so much from French
|
||||
// that the collisions are the rule rather than the exception: chat, pain,
|
||||
// coin, sale, four, or, car, ton, son.
|
||||
alsoIn: 'C’est aussi un mot français · Also a word in French',
|
||||
wordBands: {
|
||||
simple: { native: 'De tous les jours', en: 'Everyday word' },
|
||||
standard: { native: 'Courant', en: 'Standard' },
|
||||
advanced: { native: 'Avancé', en: 'Advanced' },
|
||||
},
|
||||
},
|
||||
|
||||
styles: {
|
||||
natural: { native: 'Plus naturel', en: 'Natural' },
|
||||
academic: { native: 'Académique', en: 'Academic' },
|
||||
professional: { native: 'Professionnel', en: 'Professional' },
|
||||
casual: { native: 'Décontracté', en: 'Casual' },
|
||||
humorous: { native: 'Drôle', en: 'Humorous' },
|
||||
creative: { native: 'Créatif', en: 'Creative' },
|
||||
persuasive: { native: 'Persuasif', en: 'Persuasive' },
|
||||
},
|
||||
|
||||
tones: {
|
||||
general: { native: 'Général', en: 'General' },
|
||||
academic: { native: 'Académique', en: 'Academic' },
|
||||
professional: { native: 'Professionnel', en: 'Professional' },
|
||||
casual: { native: 'Décontracté', en: 'Casual' },
|
||||
humorous: { native: 'Drôle', en: 'Humorous' },
|
||||
creative: { native: 'Créatif', en: 'Creative' },
|
||||
persuasive: { native: 'Persuasif', en: 'Persuasive' },
|
||||
},
|
||||
|
||||
exports: {
|
||||
label: 'Exporter',
|
||||
print: 'Imprimer / PDF',
|
||||
formats: {
|
||||
md: { native: 'Markdown', en: 'Markdown (.md)' },
|
||||
docx: { native: 'Document Word', en: 'Word (.docx)' },
|
||||
html: { native: 'Page web', en: 'Web page (.html)' },
|
||||
txt: { native: 'Texte brut', en: 'Plain text (.txt)' },
|
||||
},
|
||||
},
|
||||
|
||||
garden: {
|
||||
title: 'Jardin de mots · Vocabulary Garden',
|
||||
titleWithFlower: '🌷 Jardin de mots · Vocabulary Garden',
|
||||
reviewing: 'Révision · Reviewing — recall, then grade yourself',
|
||||
subtitle: 'Words you looked up, blooming as you learn them',
|
||||
reviewDue: (n) => `Réviser ${n} mot${n === 1 ? '' : 's'} · Review ${n} due 🌸`,
|
||||
emptyLead: 'Ton jardin est encore vide.',
|
||||
emptyHint: 'Fais un clic droit sur un mot anglais pour le chercher — et il germera ici.',
|
||||
due: 'à réviser · due',
|
||||
seen: (reps, intervalDays) => `revu ${reps}× · seen ${reps}× · intervalle ${intervalDays} j`,
|
||||
readAloud: '🔊 Lire',
|
||||
readSlowly: '🐢 Lentement',
|
||||
source: '📄 Source',
|
||||
remove: '🗑 Retirer',
|
||||
growing: (n) => `🐱💤 ${n} fleur${n === 1 ? '' : 's'} au jardin · ${n} blossom${n > 1 ? 's' : ''} growing`,
|
||||
end: 'Terminer · End',
|
||||
promptProduction: 'Quel est le mot anglais ? · Which English word?',
|
||||
promptRecognition: 'Qu’est-ce que ça veut dire ? · What does this mean?',
|
||||
showAnswer: 'Voir la réponse · Show answer',
|
||||
gradeAgain: { native: 'À revoir', en: 'Again' },
|
||||
gradeGood: { native: 'Je m’en souviens', en: 'Good' },
|
||||
gradeEasy: { native: 'Facile', en: 'Easy' },
|
||||
},
|
||||
|
||||
journal: {
|
||||
tabGarden: '🌷 Jardin · Garden',
|
||||
tabJournal: '🌱 Progrès · Growth',
|
||||
subtitle: 'Your own writing, month by month — only ever you and your past self',
|
||||
empty: 'Écris encore un peu — cette page pousse à partir de ton propre travail. · Keep writing; this page grows out of your own work.',
|
||||
keptHead: 'Ce mois-ci · This month',
|
||||
kept: (n) => `${n} chose${n === 1 ? '' : 's'} que tu as retenue${n === 1 ? '' : 's'} · ${n} thing${n === 1 ? '' : 's'} you took on board`,
|
||||
keptBefore: (n) => `${n} le mois précédent · ${n} the month before`,
|
||||
stuckHead: 'Resté avec toi · Stayed with you',
|
||||
stuck: (phrase, docs) =>
|
||||
`« ${phrase} » — tu l’écris toute seule maintenant, dans ${docs} de tes textes · now in ${docs} of your pieces`,
|
||||
fadedHead: 'Tu n’as plus besoin de corriger · You stopped needing this',
|
||||
faded: (pattern, times) =>
|
||||
`« ${pattern} » — ${times}× à l’époque, aucune ce mois-ci · ${times}× back then, none this month`,
|
||||
cheerStuck: (phrase) => ({
|
||||
native: `Tu écris « ${phrase} » toute seule maintenant ! 🌱`,
|
||||
en: `You’re using “${phrase}” on your own now! 🌱`,
|
||||
}),
|
||||
cheerFaded: (pattern) => ({
|
||||
native: `Ça fait un moment que « ${pattern} » n’a plus besoin d’être repris 😌`,
|
||||
en: `“${pattern}” hasn’t needed fixing in a while 😌`,
|
||||
}),
|
||||
},
|
||||
|
||||
history: {
|
||||
title: 'Historique · History',
|
||||
kinds: {
|
||||
manual: { native: 'Point gardé', en: 'Saved point' },
|
||||
auto: { native: 'Automatique', en: 'Auto' },
|
||||
pre_restore: { native: 'Avant restauration', en: 'Before restore' },
|
||||
},
|
||||
justNow: 'just now · à l’instant',
|
||||
minutesAgo: (n) => `${n} min ago · il y a ${n} min`,
|
||||
hoursAgo: (n) => `${n} hr ago · il y a ${n} h`,
|
||||
daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago · il y a ${n} jour${n > 1 ? 's' : ''}`,
|
||||
preview: 'Aperçu · Preview',
|
||||
restoring: 'Restoring…',
|
||||
restoreThis: 'Restaurer cette version · Restore this version',
|
||||
passport: '📜 Passeport d’écriture · Writing passport',
|
||||
keepFullHistory: 'Garder tout l’historique · Keep full history',
|
||||
},
|
||||
|
||||
status: {
|
||||
savedLocally: 'Gardé sur cet appareil · Kept on this device',
|
||||
helperRestingNative: 'L’assistant se repose',
|
||||
helperRestingEn: "· Petal's helper is resting · ton texte est enregistré",
|
||||
soundsOn: 'Sons activés · Sounds on',
|
||||
soundsOff: 'Sons coupés · Sounds off',
|
||||
petalsOn: 'Pétales activés · Petals on',
|
||||
petalsOff: 'Pétales coupés · Petals off',
|
||||
statsTitle: 'Statistiques · Writing stats',
|
||||
stats: {
|
||||
words: { native: 'Mots', en: 'Words' },
|
||||
characters: { native: 'Caractères', en: 'Characters' },
|
||||
sentences: { native: 'Phrases', en: 'Sentences' },
|
||||
paragraphs: { native: 'Paragraphes', en: 'Paragraphs' },
|
||||
pages: { native: 'Pages', en: 'Pages' },
|
||||
readingTime: { native: 'Temps de lecture', en: 'Reading time' },
|
||||
avgWordLength: { native: 'Longueur moyenne', en: 'Avg word length' },
|
||||
variety: { native: 'Variété du vocabulaire', en: 'Word variety' },
|
||||
readability: { native: 'Niveau de lecture', en: 'Reading level' },
|
||||
},
|
||||
readability: {
|
||||
easy: { native: 'Facile', en: 'Easy' },
|
||||
standard: { native: 'Courant', en: 'Standard' },
|
||||
fairlyHard: { native: 'Assez difficile', en: 'Fairly hard' },
|
||||
advanced: { native: 'Avancé', en: 'Advanced' },
|
||||
},
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
untitledHeading: '(sans titre)',
|
||||
outline: 'Plan · Outline',
|
||||
outlineHint: 'Utilise H1/H2/H3 pour créer des titres, et le plan apparaîtra ici.',
|
||||
},
|
||||
|
||||
update: {
|
||||
available: 'Une nouvelle version est disponible',
|
||||
refresh: 'Actualiser · Refresh',
|
||||
dismiss: 'Plus tard · Dismiss',
|
||||
},
|
||||
}
|
||||
+20
-11
@@ -1,12 +1,21 @@
|
||||
// The European Portuguese pack — the first pair that is not Chinese, and the
|
||||
// one that proves a language really is data.
|
||||
//
|
||||
// ⚠️ WRITTEN BUT NOT YET REVIEWED BY A NATIVE SPEAKER.
|
||||
// ⚠️ REVIEWED BY FOUR MODELS, NOT BY A pt-PT SPEAKER.
|
||||
// SUGGESTIONS.md §3 sets the bar: "the pt-PT pack should be reviewed by a pt-PT
|
||||
// speaker before it's trusted — same standard the zh copy got by being written
|
||||
// for a real reader." That review has not happened. Until it does, treat every
|
||||
// line here as a good-faith draft rather than as shipped copy, and expect a
|
||||
// speaker to change the register long before they change the vocabulary.
|
||||
// for a real reader." That has still not happened. What has happened
|
||||
// (2026-07-27) is an interim pass: four different models read this file
|
||||
// independently as European Portuguese speakers, and only findings at least two
|
||||
// of them reached on their own were applied — see BUILD_PLAN Phase 24.
|
||||
//
|
||||
// It found something worth the whole exercise: this file was carrying
|
||||
// **pre-Acordo spellings** — *adjectivos*, *actualmente* — in direct
|
||||
// contradiction of the paragraph directly below, and *decepção*, which is the
|
||||
// Brazilian form. The test greps guarded against Brazilian vocabulary and never
|
||||
// against the pack's own stated spelling policy; they do now. Treat this as a
|
||||
// better-checked draft, and still expect a speaker to change the register long
|
||||
// before they change the vocabulary.
|
||||
//
|
||||
// European Portuguese, not Brazilian. That is the single most likely way for
|
||||
// this file to go quietly wrong, so the choices are deliberate throughout:
|
||||
@@ -93,7 +102,7 @@ export const ptPT: Pack = {
|
||||
{ native: 'A tua cama deve estar com saudades 🛏️', en: 'I bet your bed is missing you right now.' },
|
||||
{ native: 'Cansada não se escreve bem — vai descansar 🌙', en: 'A tired writer is a bad writer — get some rest.' },
|
||||
{ native: 'Dorme sobre o assunto, que as ideias vêm sozinhas ✨', en: 'Sleep is a wondrous enabler.' },
|
||||
{ native: 'Ouves? Pois não… está toda a gente a dormir, e tu também devias 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
{ native: 'Ouves? Pois não ouves… está toda a gente a dormir, e tu também devias 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
|
||||
// Portuguese proverbs on sleep and haste, in place of the Chinese ones —
|
||||
// a pack is not a translation of another pack.
|
||||
{ native: 'Deitar cedo e cedo erguer dá saúde e faz crescer.', en: 'Early to bed and early to rise makes you healthy and helps you grow.' },
|
||||
@@ -135,7 +144,7 @@ export const ptPT: Pack = {
|
||||
names: {
|
||||
cat: 'Gato dorminhoco',
|
||||
dog: 'Cão contente',
|
||||
'wiggle-dog': 'Cão abanão',
|
||||
'wiggle-dog': 'Cão abana-rabo',
|
||||
butterfly: 'Borboleta',
|
||||
parrot: 'Papagaio',
|
||||
},
|
||||
@@ -170,7 +179,7 @@ export const ptPT: Pack = {
|
||||
ageIsNotHave: (years) => `Em inglês a idade é com o verbo *to be*, não com *have*: “I am ${years} years old”.`,
|
||||
agreeIsAVerb: '“Agree” já é o verbo — não leva *to be* à frente: diz-se “I agree”.',
|
||||
forNotSince: (duration) => `Para uma duração usa-se “for”: “for ${duration}”. O “since” marca o início (since 2020).`,
|
||||
veryBeforeVerb: (verb) => `“Very” só acompanha adjectivos, não verbos: “really ${verb}”, ou “${verb} … very much”.`,
|
||||
veryBeforeVerb: (verb) => `“Very” só acompanha adjetivos, não verbos: “really ${verb}”, ou “${verb} … very much”.`,
|
||||
turnOnNotOpen: (thing, on) => `Em inglês os aparelhos não se abrem nem se fecham — ligam-se e desligam-se: “turn ${on ? 'on' : 'off'} the ${thing}”.`,
|
||||
althoughOrBut: (word) => `Em inglês usa-se “${word}” ou “but”, nunca os dois na mesma frase.`,
|
||||
},
|
||||
@@ -181,8 +190,8 @@ export const ptPT: Pack = {
|
||||
// que ela queria.
|
||||
falseFriends: {
|
||||
actually: {
|
||||
native: '“Actually” quer dizer *na verdade*, não *actualmente*. Para “actualmente” diz-se “currently” / “nowadays”.',
|
||||
en: '“Actually” means *in fact*. For the Portuguese *actualmente*, English uses “currently”.',
|
||||
native: '“Actually” quer dizer *na verdade*, não *atualmente*. Para “atualmente” diz-se “currently” / “nowadays”.',
|
||||
en: '“Actually” means *in fact*. For the Portuguese *atualmente*, English uses “currently”.',
|
||||
},
|
||||
eventually: {
|
||||
native: '“Eventually” quer dizer *por fim, mais cedo ou mais tarde* — não *eventualmente*. Para isso: “possibly” ou “if necessary”.',
|
||||
@@ -245,8 +254,8 @@ export const ptPT: Pack = {
|
||||
en: '“Costume” is fancy dress; *costumes* are “customs”.',
|
||||
},
|
||||
deception: {
|
||||
native: '“Deception” é *engano*. A *decepção* é “disappointment”.',
|
||||
en: '“Deception” means being misled; *decepção* is “disappointment”.',
|
||||
native: '“Deception” é *engano*. A *deceção* é “disappointment”.',
|
||||
en: '“Deception” means being misled; *deceção* is “disappointment”.',
|
||||
},
|
||||
injury: {
|
||||
native: '“Injury” é uma *lesão*. A *injúria* é “insult”.',
|
||||
|
||||
Reference in New Issue
Block a user