Files

699 lines
38 KiB
JavaScript
Raw Permalink Normal View History

/*
* pt-variants.js — one NLLB translation, two Portugueses.
*
* Measured on this church's own 4000-sentence transcript corpus: NLLB's
* por_Latn output is ~8:1 European-marked, but not consistently either
* variant. No available translation model can steer the variant (both
* candidates were benchmarked and rejected — one produced the wrong variant,
* the other produced pan-Romance interlingua). So the split is deterministic:
*
* raw NLLB ─→ toEuropean() → "Português (Portugal)"
* └─→ toBrazilian() → "Português (Brasil)"
*
* Both passes were built against the real corpus with adversarial review
* (0 breaks), cost ~35µs/sentence, and are idempotent. The rules err on the
* side of leaving text alone: every transformation carries measured guards
* (see the 'sumo sacerdote' lookahead — a blind sumo→suco swap would have
* read "juice priest" in a sermon on Hebrews).
*
* Corpus-verified whitelists live inside; do not extend them by intuition —
* re-run the regression harnesses in the project scratchpad notes instead.
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.PtVariants = factory();
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const EU = (function () {
/*
* localize-pt.mjs — deterministic BR->PT (European Portuguese) post-pass.
* Input: NLLB por_Latn output. Output: same text nudged toward pt-PT.
* Design rule: every transformation must be SAFE — when unsure, leave it alone.
*
* TRAP, measured the hard way: JavaScript's \b is defined over [A-Za-z0-9_] only, so
* /\bônibus\b/ does NOT match "o ônibus" and /\b… (é)\b/ does not match "o fato é".
* Several lexicon entries were silently dead until this was caught. Every word boundary
* in this file therefore goes through RE(), which uses accent-aware lookarounds.
*/
const LETTER = 'A-Za-zÀ-ÿ';
const RE = (body, flags = 'g') =>
new RegExp(`(?<![${LETTER}])(?:${body})(?![${LETTER}])`, flags);
// ---------- 1. gerund -> infinitive ----------
// Words ending in -ndo that are NOT gerunds. Guards against "está quando", noun heads, etc.
const NOT_GERUND = new Set([
'quando', 'mundo', 'segundo', 'profundo', 'fundo', 'redondo', 'bando', 'memorando',
'reverendo', 'imundo', 'oriundo', 'rotundo', 'moribundo', 'estupendo', 'tremendo',
'horrendo', 'nefando', 'venerando', 'comando', 'bando', 'contrabando', 'blando',
'grando', 'furibundo', 'vagabundo', 'mando', 'brando', 'lindo', 'infindo', 'findo',
// 1st-person-singular present forms that merely LOOK like gerunds:
// entendo "I understand", pretendo, defendo, ofendo, atendo, acendo, estendo, ascendo,
// suspendo, respondo, escondo, confundo, difundo, ando, mando, brando…
'entendo', 'pretendo', 'defendo', 'ofendo', 'atendo', 'acendo', 'estendo', 'ascendo',
'suspendo', 'respondo', 'escondo', 'confundo', 'difundo', 'infundo', 'refundo',
'descendo', 'transcendo', 'contendo', 'retendo', 'obtendo', 'detendo', 'abstendo',
]);
// -uzir verbs form the gerund in -uzindo; a stray "-uzendo" is a malformed input, not a
// verb we can safely derive from. Leave those alone rather than emit "conduzer".
const BAD_STEM = /uzendo$/i;
// Irregular / special gerund stems.
const GERUND_IRREG = new Map([
['indo', 'ir'], ['vindo', 'vir'], ['pondo', 'pôr'],
['compondo', 'compor'], ['propondo', 'propor'], ['dispondo', 'dispor'],
['supondo', 'supor'], ['impondo', 'impor'], ['expondo', 'expor'], ['repondo', 'repor'],
['tendo', 'ter'], ['sendo', 'ser'], ['vendo', 'ver'], ['lendo', 'ler'],
['crendo', 'crer'], ['dando', 'dar'], ['rindo', 'rir'],
['advindo', 'advir'], ['convindo', 'convir'], ['intervindo', 'intervir'],
]);
function gerundToInfinitive(g) {
const low = g.toLowerCase();
if (NOT_GERUND.has(low)) return null;
if (GERUND_IRREG.has(low)) return GERUND_IRREG.get(low);
if (BAD_STEM.test(low)) return null;
if (low.length < 6) return null; // too short to be a safe gerund
if (low.endsWith('ando')) return low.slice(0, -4) + 'ar';
if (low.endsWith('endo')) return low.slice(0, -4) + 'er';
if (low.endsWith('indo')) return low.slice(0, -4) + 'ir';
return null;
}
// ---------- 2. enclitic attachment to an infinitive ----------
// "atrair" + "te" -> "atrair-te" ; "atrair" + "o" -> "atraí-lo"
const DIRECT_3 = new Set(['o', 'a', 'os', 'as']);
function attachClitic(inf, pron) {
const p = pron.toLowerCase();
if (!DIRECT_3.has(p)) return `${inf}-${p}`; // se/me/te/nos/vos/lhe/lhes attach cleanly
// 3rd-person direct object: drop the -r, accent the theme vowel, use l-form.
if (inf === 'pôr') return `pô-l${p}`;
if (inf.endsWith('ar')) return inf.slice(0, -2) + 'á-l' + p;
if (inf.endsWith('er')) return inf.slice(0, -2) + 'ê-l' + p;
if (inf.endsWith('ir')) return inf.slice(0, -2) + 'i-l' + p;
return `${inf}-${p}`;
}
// ---------- 3. progressive: estar + [clitic] + GERUND -> estar a + INF[-clitic] ----------
const ESTAR = '(?:estou|estás|está|estamos|estais|estão|estava|estavas|estávamos|estavam|estive|esteve|estivemos|estiveram|estarei|estará|estaremos|estarão|estaria|estariam|estivesse|estivessem|esteja|estejam|estejamos)';
const CLITIC = '(?:se|me|te|nos|vos|lhe|lhes|o|a|os|as)';
function fixProgressive(text) {
// with a clitic between estar and the gerund: "está se movendo" -> "está a mover-se"
let out = text.replace(
RE(`(${ESTAR})(\\s+)(${CLITIC})(\\s+)([A-Za-zÀ-ÿ]+ndo)\\b`, 'gi'),
(m, est, s1, pron, s2, ger) => {
const inf = gerundToInfinitive(ger);
if (!inf) return m;
return `${est}${s1}a ${attachClitic(inf, pron)}`;
});
// clitic ALREADY attached to the gerund: "está levando-o" -> "está a levá-lo".
// Must run before the plain rule, which would otherwise strand the pronoun as "a levar-o".
out = out.replace(
RE(`(${ESTAR})(\\s+)([A-Za-zÀ-ÿ]+ndo)-(${CLITIC})\\b`, 'gi'),
(m, est, s1, ger, pron) => {
const inf = gerundToInfinitive(ger);
if (!inf) return m;
return `${est}${s1}a ${attachClitic(inf, pron)}`;
});
// plain: "está falando" -> "está a falar"
out = out.replace(
RE(`(${ESTAR})(\\s+)([A-Za-zÀ-ÿ]+ndo)\\b`, 'gi'),
(m, est, s1, ger) => {
const inf = gerundToInfinitive(ger);
if (!inf) return m;
return `${est}${s1}a ${inf}`;
});
// "continua/anda/vai + gerund" is also BR-leaning; pt-PT prefers "a + inf"
out = out.replace(
RE(`(continua|continuam|continuo|continuamos|anda|andam|ando|andamos)(\\s+)([A-Za-zÀ-ÿ]+ndo)`, 'gi'),
(m, v, s1, ger) => { const inf = gerundToInfinitive(ger); return inf ? `${v}${s1}a ${inf}` : m; });
return out;
}
// ---------- 4. lexical table ----------
// Each entry: [BR regex, PT replacement]. Case-preserving for sentence-initial capitals.
// Deliberately conservative: only pairs where the BR word is unambiguous in a sermon register.
const LEXICON = [
// --- transport / objects ---
['trem', 'comboio'], ['trens', 'comboios'],
['ônibus', 'autocarro'],
['celular', 'telemóvel'], ['celulares', 'telemóveis'],
['geladeira', 'frigorífico'], ['geladeiras', 'frigoríficos'],
['xícara', 'chávena'], ['xícaras', 'chávenas'],
['sanduíche', 'sandes'], ['sanduíches', 'sandes'],
['pedágio', 'portagem'], ['ponto de ônibus', 'paragem de autocarro'],
['banheiro', 'casa de banho'], ['banheiros', 'casas de banho'],
['açougue', 'talho'], ['aeromoça', 'hospedeira'],
['calçada', 'passeio'], ['favela', 'bairro de lata'],
['grampeador', 'agrafador'], ['mouse', 'rato'],
['terno', 'fato'], ['meias', 'peúgas'],
// --- everyday nouns ---
['café da manhã', 'pequeno-almoço'],
['sobrenome', 'apelido'], ['sobrenomes', 'apelidos'],
['carona', 'boleia'],
['sorvete', 'gelado'], ['sorvetes', 'gelados'],
['bala', 'rebuçado'], ['balas', 'rebuçados'],
['presunto', 'fiambre'], ['bacon', 'toucinho'],
['grama', 'relva'], ['ônibus escolar', 'autocarro escolar'],
['esporte', 'desporto'], ['esportes', 'desportos'],
['esportivo', 'desportivo'], ['esportiva', 'desportiva'],
['time', 'equipa'], ['times', 'equipas'],
['equipe', 'equipa'], ['equipes', 'equipas'],
['torcida', 'adeptos'], ['torcedor', 'adepto'], ['torcedores', 'adeptos'],
// --- tech / admin ---
['tela', 'ecrã'], ['telas', 'ecrãs'],
['arquivo', 'ficheiro'], ['arquivos', 'ficheiros'],
['registro', 'registo'], ['registros', 'registos'],
['cadastro', 'registo'],
['aplicativo', 'aplicação'], ['aplicativos', 'aplicações'],
['usuário', 'utilizador'], ['usuários', 'utilizadores'],
['senha', 'palavra-passe'], ['senhas', 'palavras-passe'],
['time de trabalho', 'equipa de trabalho'],
['fone', 'auscultador'], ['fones', 'auscultadores'],
['caixa de correio', 'caixa de correio'],
// --- people ---
['garoto', 'rapaz'], ['garotos', 'rapazes'],
['garota', 'rapariga'], ['garotas', 'raparigas'],
['moleque', 'miúdo'], ['moleques', 'miúdos'],
['criançada', 'miudagem'],
['bunda', 'rabo'],
// REMOVED after measurement — 'cara' is a homograph: it means "face" in BOTH variants
// and only means "dude" as BR slang. The blind swap produced "tipo a tipo" for
// "cara a cara" (face to face) and "tens a tipo" for "tens a cara". Net harm.
// --- verbs / expressions ---
['tomar café da manhã', 'tomar o pequeno-almoço'],
['pegar o trem', 'apanhar o comboio'],
['pegar o ônibus', 'apanhar o autocarro'],
// REMOVED after measurement: 'ligar para' -> 'telefonar a' is right only when the object
// is a person. On "get a hold of this" it produced "telefonar a isto". 0 wins, 1 loss.
['bagunça', 'confusão'],
['bacana', 'giro'], ['maneiro', 'giro'],
['grana', 'dinheiro'],
['trabalhar duro', 'trabalhar arduamente'],
// REMOVED after measurement:
// 'legal' -> 'fixe' : 'legal' also means "lawful" in both variants. Too risky in a
// sermon ("a exigência legal"). One lucky hit, unbounded downside.
// 'virar' -> 'tornar-se': 'virar' is polysemous (turn / turn into / be around the corner)
// and 'tornar-se' is reflexive, so it cannot take the direct object
// 'virar' takes. Produced "se tornar-se contra ele" (double
// reflexive) and "tornar-se a árvore genealógica". 3 breaks, 0 wins.
// --- religious register (safe, both understood, PT preference) ---
['culto de domingo', 'culto de domingo'],
['bispo', 'bispo'],
];
// Words that are genuinely different but too risky to swap blind, kept out on purpose:
// fato (BR "suit" vs PT "fact") — homograph collision with the PT word we WANT.
// fila / carro / entender / ótimo — used in both variants.
// você — changing it requires re-conjugating the verb (see fixVoce).
/* ----- gender flips -----
* Several pairs change grammatical gender ("a tela" f -> "o ecrã" m). Swapping the noun
* alone yields "na ecrã", which is ungrammatical. These entries carry the target gender so
* the determiner in front of them can be rewritten too. */
const GENDER_FLIP = new Map([
['ecrã', 'm'], ['ecrãs', 'm'], ['frigorífico', 'm'], ['frigoríficos', 'm'],
['sandes', 'f'], ['equipa', 'f'], ['equipas', 'f'], ['aplicação', 'f'], ['aplicações', 'f'],
['casa de banho', 'f'], ['casas de banho', 'f'], ['rebuçado', 'm'], ['rebuçados', 'm'],
['passeio', 'm'], ['portagem', 'f'], ['bairro de lata', 'm'], ['rabo', 'm'],
['adeptos', 'm'], ['relva', 'f'], ['boleia', 'f'],
]);
// Determiners/contractions, feminine <-> masculine. Plural forms included.
const DET_F2M = {
a: 'o', as: 'os', uma: 'um', umas: 'uns', na: 'no', nas: 'nos', da: 'do', das: 'dos',
à: 'ao', às: 'aos', pela: 'pelo', pelas: 'pelos', esta: 'este', estas: 'estes',
essa: 'esse', essas: 'esses', aquela: 'aquele', aquelas: 'aqueles', minha: 'meu',
minhas: 'meus', tua: 'teu', tuas: 'teus', sua: 'seu', suas: 'seus', nossa: 'nosso',
nossas: 'nossos', vossa: 'vosso', vossas: 'vossos', toda: 'todo', todas: 'todos',
outra: 'outro', outras: 'outros', muita: 'muito', muitas: 'muitos', pouca: 'pouco',
alguma: 'algum', algumas: 'alguns', nenhuma: 'nenhum', primeira: 'primeiro',
mesma: 'mesmo', mesmas: 'mesmos', própria: 'próprio',
};
const DET_M2F = Object.fromEntries(Object.entries(DET_F2M).map(([f, m]) => [m, f]));
const DET_ALT = Object.keys(DET_F2M).concat(Object.keys(DET_M2F))
.sort((a, b) => b.length - a.length).join('|');
function matchCase(src, repl) {
if (src[0] === src[0].toUpperCase() && src[0] !== src[0].toLowerCase())
return repl[0].toUpperCase() + repl.slice(1);
return repl;
}
const LEX_RE = LEXICON
.slice()
.sort((a, b) => b[0].length - a[0].length) // longest match first
.map(([br, pt]) => {
const esc = br.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const g = GENDER_FLIP.get(pt);
// When the replacement flips gender, capture any determiner sitting in front of it.
return [RE(`(?:(${DET_ALT})\\s+)?${esc}`, 'gi'), pt, g];
});
function fixLexicon(text) {
let out = text;
for (const [re, pt, gender] of LEX_RE) {
out = out.replace(re, (m, det) => {
const noun = matchCase(det ? m.slice(m.indexOf(det) + det.length).trimStart() : m, pt);
if (!det) return noun;
const map = gender === 'm' ? DET_F2M : gender === 'f' ? DET_M2F : null;
const fixed = map && map[det.toLowerCase()] ? matchCase(det, map[det.toLowerCase()]) : det;
return `${fixed} ${noun}`;
});
}
return out;
}
// ---------- 5. the "fato" problem, handled narrowly ----------
// BR writes "o fato é que" / "de fato" where PT writes "facto". Only touch these fixed frames.
function fixFacto(text) {
return text
.replace(RE(`de fato`, 'gi'), m => matchCase(m, 'de facto'))
.replace(RE(`(o|O) fato (é|de|que)`, 'g'), (m, a, b) => `${a} facto ${b}`)
.replace(RE(`(os|Os) fatos`, 'g'), (m, a) => `${a} factos`);
}
// ---------- 6. "você" — attempted narrowly, or not at all ----------
// A full você->tu rewrite needs verb re-conjugation across every tense: NOT attempted.
// What IS safe: dropping a redundant SUBJECT "você" before a 3sg verb. The remaining
// verb stays 3sg, which in pt-PT reads as the neutral/formal address. Grammatical, natural.
function fixVoce(text, { dropSubject = true } = {}) {
if (!dropSubject) return text;
let out = text;
// sentence-initial or post-comma "Você <verb>" -> "<verb>" (only before estar/ir/ter/poder/dever forms
// and the "a + inf" progressive we just built, where 3sg is unambiguous)
out = out.replace(/(^|[.!?;]\s+|,\s+)([Vv]ocê)\s+(está|estava|estará|estaria|esteja|vai|ia|irá|tem|tinha|terá|pode|podia|poderá|deve|devia|deverá|precisa|precisava|quer|queria)\b/g,
(m, pre, v, verb) => {
const cap = /^[.!?;]?\s*$/.test(pre) || pre === '';
return pre + (cap ? verb.charAt(0).toUpperCase() + verb.slice(1) : verb);
});
// object "para você" -> "para si" (pt-PT formal).
// MUST use RE(): /\bpara você\b/ also fires inside "para vocês" (JS \b is ASCII-only, so
// the boundary after "ê" misbehaves), which produced "para sis". Plural is left alone.
out = out.replace(RE(`para você`, 'gi'), m => matchCase(m, 'para si'));
return out;
}
// ---------- pipeline ----------
function localizeToEuropean(text, opts = {}) {
let t = text;
t = fixProgressive(t);
t = fixLexicon(t);
t = fixFacto(t);
if (opts.voce !== false) t = fixVoce(t);
return t;
}
return { localizeToEuropean };
})();
const BR = (function () {
/*
* localize-br.mjs — deterministic EU->BR (Brazilian Portuguese) post-pass.
* Input: raw NLLB por_Latn output (which is ~8:1 European-marked on this corpus).
* Output: same text nudged toward pt-BR. Sibling of localize-pt.mjs; same design
* rule: every transformation must be SAFE — when unsure, leave it alone.
*
* Corrections vs the plan (PLAN-pt-and-local-voices.md item 1), all MEASURED on the
* 3941-sentence archive corpus:
*
* (c) 2sg->3sg: the plan's regular rules ("present -as/-es -> -a/-e") are NOT run
* as open regexes. Measured: 1469 corpus tokens end in -as/-es and ~83% are
* plural nouns/determiners ("pessoas", "suas", "boas", "das", "vezes"). A
* 'follows tu' guard is useless the other way: only 18 tokens follow "tu",
* but real 2sg verbs are pro-dropped everywhere ("sabes" x30, no "tu").
* => WHITELIST of verified 2sg forms (irregulars + every regular 2sg form
* actually observed in the corpus, each checked for noun homographs).
* Excluded on homograph grounds: deste/destes (de+este contraction, 10x in
* corpus, 0 as verb), vales (valleys), veste (3sg/noun), existe (-iste trap).
* Hosts carrying an enclitic ("lembras-te", "tornas-te") are skipped: fixing
* the verb while enclisis stays produces an imperative reading ("Lembra-te").
*
* (b) enclitic->proclitic: the plan's "any non-clause-initial verb" rule was
* measured against a strict attractor whitelist on all 408 corpus enclitics.
* The plan rule moves 128 sentences; its only break class is imperatives
* after a conjunction ("E deixe-o absorver" -> "E o deixe...", "Mas
* ajuda-nos" -> "Mas nos ajuda" — imperative silently becomes declarative)
* plus clitic-chain hosts ("Deixem-me dizer-vos" -> "Deixem-me vos dizer").
* => shipped guard: move UNLESS the immediately-preceding token is a
* conjunction/article/preposition-'a'/clitic (BLOCKERS) or any punctuation
* intervenes (covers sentence/clause-initial and vocatives: "Deus,
* deixe-nos..." stays). Never moves: l-forms (fazê-lo, vê-los — stem
* changed; restoring the stem is only safe under estar-a where we control
* both sides), n-forms after nasal hosts (louvem-no: 'no' may be 'o'; and
* "matem-nos" may be 'us' or 'them' — genuinely ambiguous), gerund hosts
* (BR enclisis to gerunds is natural), mesoclisis (0 in corpus, guarded).
*
* (a) estar a + INF -> gerund: infinitive detection is shape + blocklist, not
* shape alone. "está a caminho" self-filters (no -r), but feminine -r nouns
* ("a par", "a colher", "a líder") would not: NOUN_R blocklist. Words of
* length <=3 must be on a verb whitelist (ir/ver/dar/...). Clitic-carrying
* infinitives are converted with the clitic riding the gerund: "está a
* referir-se" -> "está se referindo", "está a castigá-lo" -> "está
* castigando-o" (l-form stem restored — safe here because we build both
* sides). Extended to continuar-family (5 corpus hits), same guards.
*
* (d) lexicon: sumo->suco is guarded against "sumo sacerdote" (HIGH PRIEST — the
* corpus's only 'sumo' is this one!). rezar->orar guarded against the
* "reza a lenda / o ditado / assim" frame. rapariga->moça added (pejorative
* in BR), miúdos->crianças (BR 'miúdos' = giblets) with determiner gender
* flip, plus EP spellings NLLB emits (facto->fato x6, óptimo->ótimo).
* 'para si'/'consigo' deliberately NOT touched (EP 'you' vs BR reflexive —
* polysemous, and misreading is harmless, not wrong-register).
*
* Clitic 'te' is KEPT (plan policy: BR evangelical register mixes você+te).
*/
const LETTER = 'A-Za-zÀ-ÿ';
const RE = (body, flags = 'g') =>
new RegExp(`(?<![${LETTER}])(?:${body})(?![${LETTER}])`, flags);
function matchCase(src, repl) {
if (src[0] === src[0].toUpperCase() && src[0] !== src[0].toLowerCase())
return repl[0].toUpperCase() + repl.slice(1);
return repl;
}
// ============================ (a) estar a + INF -> gerund ============================
// 'estar'/'estares'/'estarmos' included: "vou estar a preparar-me" (3 corpus hits
// were missed without the bare infinitive).
const ESTAR = '(?:estou|estás|está|estamos|estais|estão|estava|estavas|estávamos|estavam|estive|esteve|estivemos|estiveram|estarei|estarás|estará|estaremos|estarão|estaria|estarias|estariam|estivesse|estivesses|estivessem|estiver|estiveres|estivermos|estiverem|esteja|estejas|estejam|estejamos|estar|estares|estarmos)';
// continuar-family: same EU periphrasis, 5 corpus hits ("continua a andar").
const CONT = '(?:continuo|continuas|continua|continuamos|continuam|continuar|continuava|continuavam|continuou|continue|continuem)';
const AUX_A = `(?:${ESTAR}|${CONT})`;
// -r words that are NOT infinitives (nouns/adjectives/advs). The corpus's non-verb
// "estar a X" cases self-filter on shape ("caminho", "morte", "fé"), but these
// would not. Feminine ones ("a par", "a colher") are the live risk.
const NOUN_R = new Set([
'ar', 'par', 'mar', 'lar', 'luar', 'bar', 'altar', 'açúcar', 'lugar', 'jantar',
'dólar', 'exemplar', 'militar', 'pilar', 'nectar', 'néctar', 'hangar', 'vulgar',
'mulher', 'colher', 'prazer', 'dever', 'poder', 'lazer', 'mister', 'qualquer',
'caráter', 'carácter', 'líder', 'éter', 'crer' /* keep? no: crer IS a verb */,
'mártir', 'elixir', 'nadir',
]);
NOUN_R.delete('crer');
// length<=3 words ending -r must be on this list to count as verbs.
const SHORT_VERB = new Set(['ir', 'ver', 'ler', 'dar', 'rir', 'ser', 'ter', 'vir', 'pôr']);
const GER_IRREG = new Map([['pôr', 'pondo'], ['vir', 'vindo'], ['ir', 'indo']]);
function infToGerund(w) {
const low = w.toLowerCase();
if (GER_IRREG.has(low)) return GER_IRREG.get(low);
if (NOUN_R.has(low)) return null;
if (low.length <= 3 && !SHORT_VERB.has(low)) return null;
if (/[çk]er$/.test(low)) return null; // junk stems
if (low.endsWith('ar')) return low.slice(0, -2) + 'ando';
if (low.endsWith('er')) return low.slice(0, -2) + 'endo';
if (low.endsWith('ir')) return low.slice(0, -2) + 'indo';
if (low.endsWith('or') && /p[oô]r$/.test(low)) return low.slice(0, -2) + 'ondo'; // compor->compondo
return null;
}
const CL_PRON = '(?:me|te|se|nos|vos|lhe|lhes)';
// One adverb may sit between the aux and 'a' ("Estou só a dizer-te").
const ADV = '(?:só|apenas|ainda|agora|sempre|mesmo|também|aqui|lá|hoje|realmente)';
function fixEstarA(text) {
let out = text;
// 1. l-form enclitic on the infinitive: "está a castigá-lo" -> "está castigando-o".
// Stem restoration is safe here because we synthesize both sides.
out = out.replace(
RE(`(${AUX_A})(\\s+(?:${ADV}\\s+)?)a\\s+([${LETTER}]+)([áêí])-l(o|a|os|as)`, 'gi'),
(m, aux, s1, stem, tv, cl) => {
const nd = tv === 'á' ? 'ando' : tv === 'ê' ? 'endo' : 'indo';
return `${aux}${s1}${stem}${nd}-${cl}`;
});
// "está a pô-lo" -> "está pondo-o"
out = out.replace(
RE(`(${AUX_A})(\\s+)a\\s+p([ôo])-l(o|a|os|as)`, 'gi'),
(m, aux, s1, _o, cl) => `${aux}${s1}pondo-${cl}`);
// 1b. proclitic already between 'a' and the infinitive (NLLB emits this too):
// "está a se realizar" -> "está se realizando".
out = out.replace(
RE(`(${AUX_A})(\\s+(?:${ADV}\\s+)?)a\\s+(${CL_PRON})\\s+([${LETTER}]+r)`, 'gi'),
(m, aux, s1, cl, inf) => {
const g = infToGerund(inf);
return g ? `${aux}${s1}${cl.toLowerCase()} ${g}` : m;
});
// 2. pronoun enclitic on the infinitive: "está a referir-se" -> "está se referindo".
out = out.replace(
RE(`(${AUX_A})(\\s+(?:${ADV}\\s+)?)a\\s+([${LETTER}]+r)-(${CL_PRON})`, 'gi'),
(m, aux, s1, inf, cl) => {
const g = infToGerund(inf);
return g ? `${aux}${s1}${cl.toLowerCase()} ${g}` : m;
});
// 3. plain: "está a falar" -> "está falando".
out = out.replace(
RE(`(${AUX_A})(\\s+(?:${ADV}\\s+)?)a\\s+([${LETTER}]+r)`, 'gi'),
(m, aux, s1, inf) => {
const g = infToGerund(inf);
return g ? `${aux}${s1}${g}` : m;
});
return out;
}
// ============================ (b) enclitic -> proclitic ============================
// Tokens whose presence right before the verb VETOES the move:
// - conjunctions: enclisis after them is normal EP AND the verb is often an
// imperative there ("E deixe-o...", "Mas ajuda-nos..."); moving flips the
// reading to declarative.
// - bare articles / the preposition-or-article 'a', 'o', 'os', 'as', 'à', 'ao':
// the "prev" is then part of another phrase, not a proclisis licenser.
// - clitics: prev is the tail of a preceding enclitic verb ("Deixem-me
// dizer-vos" — the first verb governs; leave the chain alone).
const BLOCKERS = new Set([
'e', 'mas', 'ou', 'então', 'pois', 'porém', 'portanto', 'contudo', 'entretanto',
'a', 'o', 'os', 'as', 'à', 'ao', 'aos', 'às', 'um', 'uma', 'uns', 'umas',
'me', 'te', 'se', 'nos', 'vos', 'lhe', 'lhes',
]);
// 'se' is genuinely both (conjunction-attractor vs clitic tail); as a clitic tail
// it follows a hyphen which the prev capture strips, so treat bare 'se' as an
// attractor EXCEPT we cannot tell — measured: corpus has no "se <verb>-<clitic>"
// where the 'se' was a clitic tail, so 'se' is removed from BLOCKERS below.
BLOCKERS.delete('se');
const CL_ALL = '(?:me|te|se|nos|vos|lhe|lhes|lo|la|los|las|no|na|nas|o|a|os|as)';
function fixEnclitic(text) {
return text.replace(
new RegExp(`([${LETTER}]+)(-?)([,;:]?)(\\s+)([${LETTER}]{3,})-(${CL_ALL})(-[${LETTER}]+)?(?![${LETTER}-])`, 'g'),
(m, prev, hyph, punct, sp, host, cl, tail) => {
if (tail) return m; // mesoclisis — skip
const lcl = cl.toLowerCase(), lhost = host.toLowerCase();
if (/^l[oa]s?$/.test(lcl)) return m; // fazê-lo — stem changed, skip (NOT lhe/lhes!)
if (/^n/.test(lcl) && /(?:m|ão|õe|ões)$/.test(lhost)) return m; // louvem-no / matem-nos — ambiguous
if (lhost.endsWith('ndo')) return m; // gerund host — BR enclisis is fine
if (punct || hyph) return m; // clause boundary / clitic chain
if (BLOCKERS.has(prev.toLowerCase())) return m;
return `${prev}${sp}${lcl} ${host}`;
});
}
// ============================ (c) tu-system -> você-system ============================
// Verified 2sg forms only. Regular -s-drop is generated; irregulars are explicit.
const IRREG_2SG = {
'és': 'é', 'estás': 'está', 'tens': 'tem', 'vens': 'vem', 'vais': 'vai',
'dás': 'dá', 'vês': 'vê', 'pões': 'põe', 'lês': 'lê', 'crês': 'crê',
'queres': 'quer', 'podes': 'pode', 'sabes': 'sabe', 'dizes': 'diz',
'fazes': 'faz', 'trazes': 'traz', 'pedes': 'pede', 'ouves': 'ouve',
'queres': 'quer',
// preterite irregulars
'foste': 'foi', 'fizeste': 'fez', 'disseste': 'disse', 'viste': 'viu',
'tiveste': 'teve', 'vieste': 'veio', 'puseste': 'pôs', 'quiseste': 'quis',
'soubeste': 'soube', 'estiveste': 'esteve', 'trouxeste': 'trouxe',
// NOTE: 'deste'/'destes' (you gave) EXCLUDED — corpus has them 10x, all as the
// de+este contraction. 'vales' EXCLUDED (valleys, in corpus).
};
// Regular present/imperfect/conditional 2sg observed in the corpus (each checked
// against noun homographs in context) + a few universal verb-dominant forms.
const REG_2SG = [
'precisas', 'andas', 'pensas', 'lembras', 'achas', 'consegues', 'preparas',
'unges', 'sentes', 'gostas', 'entendes', 'reparas', 'confias', 'usas',
'deixas', 'ficas', 'bebes', 'pecas', 'colocas', 'procuras', 'esperas',
'imaginas', 'conheces', 'caminhas', 'paras', 'arriscas', 'pagas',
'continuas', 'percebes', 'mereces', 'ofereces', 'pertences', 'estabeleces',
'castigas', 'disciplinas', 'rezas', 'falas', 'amas', 'chamas', 'chegas',
'moras', 'trabalhas', 'levas', 'olhas', 'louvas',
'adoras', 'vives', 'escreves', 'aprendes', 'recebes', 'vences',
'perdes', 'abres', 'sentas', 'voltas', 'entras', 'ganhas',
// EXCLUDED noun homographs: partes (body parts, 4x in corpus), buscas
// (searches), guardas (guards), vias (roads), vales (valleys), contas (bills).
// imperfect / conditional (-as -> -a)
'eras', 'tinhas', 'estavas', 'devias', 'podias', 'sabias', 'farias',
'querias', 'ias', 'vinhas', 'davas', 'fazias', 'dizias',
// MEASURED EXTENSION beyond the plan (which said "do not touch subjunctive"):
// 2sg SUBJUNCTIVE forms convert to 3sg by the same s-drop, and every form below
// was verified in corpus context. Excluding them leaves the text half-converted
// ("você" subjects next to "se conseguires"). Present subjunctive:
'preocupes', 'faças', 'saibas', 'sejas', 'estejas', 'venhas', 'chames',
'levantes', 'deixes', 'olhes', 'percas', 'fiques', 'envelheças', 'entres',
'respondas', 'coloques', 'acredites', 'consigas', 'desistas', 'penses',
'vejas', 'digas', 'grites', 'julgues', 'demores', 'gostes', 'apareças',
'certifiques', 'relaxes', 'enroles', 'detestes',
// imperfect subjunctive (s-drop):
'pudesses', 'estivesses', 'fizesses', 'tivesses',
// EXCLUDED: 'vivas' (adjective: "línguas vivas"), 'vires' (ver-vs-vir ambiguity).
// regular preterite observed in corpus (+ common safe ones)
'percebeste', 'conheceste', 'recebeste', 'conseguiste', 'passaste',
'paraste', 'achaste', 'sentiste', 'acordaste', 'escolheste', 'compraste',
'tomaste', 'dormiste', 'criaste', 'voltaste', 'forçaste', 'encontraste',
'preparaste', 'apareceste', 'falaste', 'amaste', 'deixaste', 'olhaste',
'perdeste', 'viveste', 'ouviste', 'pediste', 'abriste', 'decidiste',
'entregaste', 'ganhaste', 'chegaste', 'entraste', 'levaste', 'salvaste',
];
function reg2sgTo3sg(w) {
if (/aste$/.test(w)) return w.slice(0, -4) + 'ou';
if (/este$/.test(w)) return w.slice(0, -4) + 'eu';
if (/iste$/.test(w)) return w.slice(0, -4) + 'iu';
if (/s$/.test(w)) return w.slice(0, -1); // present/imperfect/conditional
return null;
}
// Future subjunctive / personal infinitive 2sg: 3sg drops '-es', not '-s'
// ("se quiseres" -> "se quiser"). Explicit map, corpus-verified forms only.
const FUTSUBJ_2SG = {
quiseres: 'quiser', tiveres: 'tiver', fizeres: 'fizer', estiveres: 'estiver',
conseguires: 'conseguir', acabares: 'acabar', chamares: 'chamar',
entrares: 'entrar', morreres: 'morrer', perceberes: 'perceber',
desceres: 'descer', disseres: 'disser', acreditares: 'acreditar',
forjares: 'forjar', precisares: 'precisar', destacares: 'destacar',
fazeres: 'fazer', acordares: 'acordar',
};
const MAP_2SG = new Map(Object.entries(IRREG_2SG));
for (const w of REG_2SG) { const t = reg2sgTo3sg(w); if (t) MAP_2SG.set(w, t); }
for (const [w, t] of Object.entries(FUTSUBJ_2SG)) MAP_2SG.set(w, t);
const RE_2SG = new RegExp(
`(?<![${LETTER}])(${[...MAP_2SG.keys()].sort((a, b) => b.length - a.length).join('|')})(?![${LETTER}-])`, 'gi');
// ^ trailing '-' guard: skip "lembras-te" etc.
function fixTuSystem(text) {
let out = text;
out = out.replace(RE_2SG, m => matchCase(m, MAP_2SG.get(m.toLowerCase()) || m));
out = out.replace(RE('contigo', 'gi'), m => matchCase(m, 'com você'));
out = out.replace(RE('convosco', 'gi'), m => matchCase(m, 'com vocês'));
out = out.replace(RE('tu', 'gi'), m => matchCase(m, 'você'));
out = out.replace(RE('ti', 'gi'), m => matchCase(m, 'você'));
out = out.replace(RE('teu', 'gi'), m => matchCase(m, 'seu'));
out = out.replace(RE('tua', 'gi'), m => matchCase(m, 'sua'));
out = out.replace(RE('teus', 'gi'), m => matchCase(m, 'seus'));
out = out.replace(RE('tuas', 'gi'), m => matchCase(m, 'suas'));
out = out.replace(RE('vosso', 'gi'), m => matchCase(m, 'seu'));
out = out.replace(RE('vossa', 'gi'), m => matchCase(m, 'sua'));
out = out.replace(RE('vossos', 'gi'), m => matchCase(m, 'seus'));
out = out.replace(RE('vossas', 'gi'), m => matchCase(m, 'suas'));
out = out.replace(RE('vós', 'gi'), m => matchCase(m, 'vocês'));
// clitic 'te' KEPT on purpose (BR evangelical register). 2pl 'vos' also kept:
// "eu vos peço" is normal BR liturgical speech.
return out;
}
// ============================ (d) lexicon + spelling ============================
// [EP form, BR form, BR gender if the swap flips the determiner]
const LEXICON = [
['telemóvel', 'celular'], ['telemóveis', 'celulares'],
['autocarro', 'ônibus'], ['autocarros', 'ônibus'],
['comboio', 'trem'], ['comboios', 'trens'],
['casa de banho', 'banheiro', 'm'], ['casas de banho', 'banheiros', 'm'],
['ecrã', 'tela', 'f'], ['ecrãs', 'telas', 'f'],
['equipa', 'equipe'], ['equipas', 'equipes'],
['connosco', 'conosco'],
['pequeno-almoço', 'café da manhã'], ['pequenos-almoços', 'cafés da manhã'],
['registo', 'registro'], ['registos', 'registros'],
['rapariga', 'moça'], ['raparigas', 'moças'], // pejorative in BR — must swap
['miúdos', 'crianças', 'f'], // BR 'miúdos' = giblets
['peúgas', 'meias'], ['peúga', 'meia'],
['desporto', 'esporte'], ['desportos', 'esportes'],
['desportivo', 'esportivo'], ['desportiva', 'esportiva'],
['frigorífico', 'geladeira', 'f'], ['frigoríficos', 'geladeiras', 'f'],
['portagem', 'pedágio', 'm'], ['autoclismo', 'descarga', 'f'],
['boleia', 'carona'], ['fiambre', 'presunto', 'm'],
['relvado', 'gramado'], ['relva', 'grama'],
['sumos', 'sucos'],
// EP spellings NLLB actually emits (facto x6, óptimo x1 in corpus):
['facto', 'fato'], ['factos', 'fatos'],
['óptimo', 'ótimo'], ['óptima', 'ótima'], ['óptimos', 'ótimos'], ['óptimas', 'ótimas'],
['contacto', 'contato'], ['contactos', 'contatos'],
['os polícias', 'os policiais'], ['dois polícias', 'dois policiais'], // EP masc. noun
// DELIBERATELY EXCLUDED:
// sumo (bare) — handled below with the 'sumo sacerdote' guard.
// fixe->legal — 'fixe' is also the subjunctive of fixar.
// giro->legal — 'giro' is also a noun (spin/round).
// apelido — EP surname vs BR nickname: conversion can invert meaning.
// para si/consigo — EP 'you' vs BR reflexive; polysemous, left alone.
// miúdo (singular) — also the adjective 'tiny'.
];
const DET_F2M = {
a: 'o', as: 'os', uma: 'um', umas: 'uns', na: 'no', nas: 'nos', da: 'do', das: 'dos',
'à': 'ao', 'às': 'aos', pela: 'pelo', pelas: 'pelos', esta: 'este', estas: 'estes',
essa: 'esse', essas: 'esses', aquela: 'aquele', aquelas: 'aqueles', minha: 'meu',
minhas: 'meus', sua: 'seu', suas: 'seus', nossa: 'nosso', nossas: 'nossos',
toda: 'todo', todas: 'todos', outra: 'outro', outras: 'outros', muita: 'muito',
muitas: 'muitos', alguma: 'algum', algumas: 'alguns', nenhuma: 'nenhum',
mesma: 'mesmo', mesmas: 'mesmos', 'própria': 'próprio', 'próprias': 'próprios',
};
const DET_M2F = Object.fromEntries(Object.entries(DET_F2M).map(([f, m]) => [m, f]));
const DET_ALT = Object.keys(DET_F2M).concat(Object.keys(DET_M2F))
.sort((a, b) => b.length - a.length).join('|');
const LEX_RE = LEXICON
.slice()
.sort((a, b) => b[0].length - a[0].length)
.map(([ep, br, g]) => {
const esc = ep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s+');
return [RE(`(?:(${DET_ALT})\\s+)?${esc}`, 'gi'), br, g || null];
});
function fixLexicon(text) {
let out = text;
for (const [re, br, gender] of LEX_RE) {
out = out.replace(re, (m, det) => {
const word = matchCase(det ? m.slice(m.indexOf(det) + det.length).trimStart() : m, br);
if (!det) return word;
const map = gender === 'm' ? DET_F2M : gender === 'f' ? DET_M2F : null;
const fixed = map && map[det.toLowerCase()] ? matchCase(det, map[det.toLowerCase()]) : det;
return `${fixed} ${word}`;
});
}
// 'sumo' -> 'suco' ONLY when it is not the adjective 'supreme'. The corpus's one
// and only 'sumo' is "sumo sacerdote" (high priest) — the blind swap would have
// produced "suco sacerdote" (juice priest).
out = out.replace(
new RegExp(`(?<![${LETTER}])sumo(?!\\s+(?:sacerdote|sacerdotes|pontífice|bem|grau))(?![${LETTER}])`, 'gi'),
m => matchCase(m, 'suco'));
// rezar -> orar (BR evangelical register), guarded against the 'the text reads'
// sense ("reza a lenda", "como reza o ditado", "reza assim").
out = out.replace(
new RegExp(`(?<![${LETTER}])rez(ar|ando|o|amos|am|a|e|em|es|ei|ou|aram|ava|avam|asse|assem)(?!\\s+(?:a\\s+lenda|o\\s+ditado|assim))(?![${LETTER}])`, 'gi'),
(m, suf) => matchCase(m, 'or' + suf.toLowerCase()));
return out;
}
// ============================ pipeline ============================
function localizeToBrazilian(text) {
let t = text;
t = fixEstarA(t); // before enclitic pass: consumes "a INF-clitic" itself
t = fixEnclitic(t);
t = fixTuSystem(t); // after enclitic: moved clitics expose clean verb hosts
t = fixLexicon(t);
return t;
}
return { localizeToBrazilian };
})();
return {
toEuropean: (t) => EU.localizeToEuropean(String(t || '')),
toBrazilian: (t) => BR.localizeToBrazilian(String(t || ''))
};
}));