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.

O link direto é este.

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.

Resultado do dicionário melhorado

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()