Incluindo milhares de termos de uma vez no corretor ortográfico do macOS
Esta semana, minha esposa migrou para o macOS. Como consequência, resolveu testar o Apple Pages, em vez do LibreOffice Writer + InDesign.
No entanto, o corretor ortográfico do macOS, que o Pages usa, não conhece muitos termos da área dela (biologia), deixando o sublinhado em vermelho. Vale ressaltar que o LibreOffice se sai muito bem nesse quesito, por usar o VERO - VERificador Ortográfico .
Com isso, havia duas soluções rápidas: 1) desabilitar o verificador ortográfico ou 2) me desafiar a resolver o problema.
Felizmente, fui convidado a tentar a opção 2.
Sendo assim, as palavras como alopátrico, sinapomorfia ou oligozoospermia, que apareciam sublinhadas em vermelho, agora são até sugeridas pelo macOS.
A solução
Peguei dicionários (exemplo) e apostilas de biologia que ela usa para estudar, e falei para o Amp ler os arquivos e gerar um .txt com termos da área.
Ele criou uma lista com mais de quatro mil termos de biologia, organizada em categorias (Biogeografia e evolução; Ecologia e habitat etc)
Como funciona
Três scripts Swift usam a API NSSpellChecker do macOS. O código completo está ao final do texto.
O learn_unknown.swift aprende apenas os termos que o corretor ainda não conhece:
swift learn_unknown.swift termos_biologia.txt
Já o learn_words.swift, força o aprendizado de todos os termos, sem verificar:
swift learn_words.swift termos_biologia.txt
Por fim, o unlearn_word.swift remove um termo individual:
swift unlearn_word.swift sinapomorfia
Os termos aprendidos deveriam ir parar em ~/Library/Spelling/LocalDictionary, mas, na prática, não funcionou. E foi a primeira coisa que tentei…
De todo modo, os script funcionam bem, e os termos passam a valer em todos os apps do sistema.
Resultado
Sem mais sublinhados vermelhos em cladograma, metagenômica ou quimioautotrofia :)
A lista pode ser complementada, modificada etc. Basta fazer o download, adicionar ou substituir linhas no .txt e rodar o script novamente.
Assim, não funciona apenas com termos de biologia, mas de qualquer área. Por exemplo, peguei esta lista de Informática e gerei esta lista, com o mesmo efeito.
Na verdade, não parei por aí: peguei o dicionário do VERO e também transformei em lista. Descrevo este processo paralelo abaixo.
VERO - de extensão LibreOffice para lista txt
Primeiro, baixei daqui: https://pt-br.libreoffice.org/projetos/vero/ o arquivo .oxt mais recente da extensão.
Tendo feito isso, renomeei o arquivo de .oxt para .zip e extraí.
Nessa altura, com outra ajuda do Amp, foi gerado um script Python, que roda o unmunch do Hunspell (formato de dicionário do VERO), para transformar numa lista de palavras simples.
A ideia veio daqui: https://forum.languagetool.org/t/dictionary-file-creation-from-hunspell-dic-and-aff/5890
Daí, seria só rodar com:
uv run ./unmunch_vero.py ./pt_BR.dic ./pt_BR.aff > ./vero_todas.txt
Porém, ao tentar adicionar as palavras dessa forma, o meu computador não aguentou o tranco…
Então tive que dividir o arquivo original em vários:
split -l 200000 vero_todas.txt
Agora sim, na pasta com os arquivos resultantes, rodei:
fd . -x swift ../learn_words.swift {}
O comando significa: ache todos os arquivos na pasta atual com fd ., execute o restante com o parâmetro -x. O {} significa o "arquivo da vez" encontrado pelo fd, então para cada arquivo, rodará o que estiver no -x.
Em resumo: adicione todas as listas de palavras, derivadas da lista original vero_todas.txt.
Como ficou, após o processo acima.

O script Python completo também está ao final.
Curiosidade
Fiquei na dúvida se precisava escrever "Biologia", com maiúscula, ou simplesmente "biologia", com minúscula.
Aqui temos a resposta: https://ciberduvidas.iscte-iul.pt/consultorio/perguntas/inicial-maiuscula-ou-minuscula-nas-disciplinas-opcional-pos-acordo-ortografico/10156#
Scripts Swift
learn_unknown.swift
import AppKit
guard CommandLine.arguments.count == 2 else {
print("Uso: swift \(CommandLine.arguments[0]) <arquivo.txt>")
exit(1)
}
let path = CommandLine.arguments[1]
let content = try! String(contentsOfFile: path, encoding: .utf8)
let checker = NSSpellChecker.shared
let lang = "pt_BR"
var learned = 0, skipped = 0
for line in content.components(separatedBy: .newlines) {
let word = line.trimmingCharacters(in: .whitespaces)
if word.isEmpty || word.hasPrefix("#") { continue }
let misspelled = checker.checkSpelling(of: word, startingAt: 0, language: lang, wrap: false, inSpellDocumentWithTag: 0, wordCount: nil)
if misspelled.location != NSNotFound {
checker.learnWord(word)
learned += 1
} else {
skipped += 1
}
if (learned + skipped) % 10000 == 0 {
print("\r\(learned + skipped) processadas, \(learned) novas...", terminator: "")
fflush(stdout)
}
}
// Aguarda flush do NSSpellChecker para disco
RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0))
print("\nPronto! \(learned) palavras novas aprendidas, \(skipped) já conhecidas.")
learn_words.swift
import AppKit
let args = CommandLine.arguments
guard args.count == 2 else {
print("Uso: swift \(args[0]) <arquivo_com_palavras.txt>")
exit(1)
}
let path = args[1]
let content = try! String(contentsOfFile: path, encoding: .utf8)
let checker = NSSpellChecker.shared
var count = 0
for line in content.components(separatedBy: .newlines) {
let word = line.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && !word.hasPrefix("#") {
checker.learnWord(word)
count += 1
}
}
// Aguarda flush do NSSpellChecker para disco
RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0))
print("\(count) palavras adicionadas ao corretor ortográfico do macOS.")
unlearn_word.swift
import AppKit
let word = CommandLine.arguments[1]
NSSpellChecker.shared.unlearnWord(word)
print("Removida: \(word)")Script Python
unmunch_vero.py
#!/usr/bin/env python3
"""Expande dicionário Hunspell (.dic + .aff) em lista plana de palavras."""
import re, sys
def parse_aff(path):
rules = {}
with open(path, encoding='utf-8', errors='replace') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split()
if len(parts) < 4 or parts[0] not in ('SFX', 'PFX'):
continue
typ, flag = parts[0], parts[1]
if len(parts) == 4:
try:
int(parts[3])
rules.setdefault(flag, {'type': typ, 'cross': parts[2] == 'Y', 'entries': []})
continue
except ValueError:
pass
if len(parts) >= 5:
strip = '' if parts[2] == '0' else parts[2]
affix_raw = parts[3]
affix = affix_raw.split('/')[0]
affix = '' if affix == '0' else affix
cont_flags = list(affix_raw.split('/')[1]) if '/' in affix_raw else []
cond = parts[4] if len(parts) > 4 else '.'
rules.setdefault(flag, {'type': typ, 'cross': True, 'entries': []})
rules[flag]['entries'].append((strip, affix, cond, cont_flags))
return rules
def apply_rule(word, strip, affix, cond, typ):
try:
if typ == 'SFX':
if cond != '.' and not re.search(cond + '$', word):
return None
if strip:
return word[:-len(strip)] + affix if word.endswith(strip) else None
return word + affix
else:
if cond != '.' and not re.search('^' + cond, word):
return None
if strip:
return affix + word[len(strip):] if word.startswith(strip) else None
return affix + word
except re.error:
return None
def expand(word, flags, rules, depth=0):
forms = {word}
if depth > 2:
return forms
derived = []
for flag in flags:
r = rules.get(flag)
if not r:
continue
for strip, affix, cond, cont in r['entries']:
result = apply_rule(word, strip, affix, cond, r['type'])
if result:
forms.add(result)
derived.append((result, r['type'], cont))
if cont:
forms |= expand(result, cont, rules, depth + 1)
# Cross products (prefix + suffix)
pfx = [(f, rules[f]) for f in flags if f in rules and rules[f]['type'] == 'PFX' and rules[f]['cross']]
sfx = [(f, rules[f]) for f in flags if f in rules and rules[f]['type'] == 'SFX' and rules[f]['cross']]
for _, pr in pfx:
for ps, pa, pc, _ in pr['entries']:
prefixed = apply_rule(word, ps, pa, pc, 'PFX')
if prefixed:
for _, sr in sfx:
for ss, sa, sc, _ in sr['entries']:
result = apply_rule(prefixed, ss, sa, sc, 'SFX')
if result:
forms.add(result)
return forms
def main():
dic = sys.argv[1]
aff = sys.argv[2]
rules = parse_aff(aff)
print(f"Regras: {len(rules)}", file=sys.stderr)
count = 0
with open(dic, encoding='utf-8', errors='replace') as f:
f.readline() # skip word count
for line in f:
line = line.split('\t')[0].strip() # ignore morphological info
if not line:
continue
if '/' in line:
w, fl = line.split('/', 1)
flags = list(fl)
else:
w, flags = line, []
for form in expand(w, flags, rules):
print(form)
count += 1
print(f"Total: {count} formas geradas", file=sys.stderr)
if __name__ == '__main__':
main()