699 lines
38 KiB
JavaScript
699 lines
38 KiB
JavaScript
/*
|
|||
|
|
* 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 || ''))
|
||
|
|
};
|
||
|
|
}));
|