Editar .docx com Controlo de Alterações do Word (Modo de Revisão)
Visão Geral
Editar programaticamente ficheiros .docx para que as alterações apareçam como revisões rastreáveis no Microsoft Word — exclusões mostram-se como riscado vermelho, inserções como sublinhado vermelho, com metadados de autor/data na margem. Isto utiliza manipulação direta do XML OOXML dos elementos w:ins / w:del via lxml, NÃO a API de alto nível do python-docx (que não suporta revisões rastreáveis).
Dependências
pip install python-docx lxml
python-docx>= 1.2.0 — usado apenas para inspeção inicial (estilos de parágrafo, formatação de segmentos). NÃO usado para as escritas de revisões rastreáveis.lxml>= 5.0 — o verdadeiro motor; analisa e modificaword/document.xmldentro do zip.docx.
Ambos são instalações Python puras, sem dependências de sistema.
Quando Usar
- O utilizador pede para editar um
.docxe quer VER as alterações no Word (Modo de Revisão / Track Changes) - O utilizador precisa enviar um manuscrito revisto onde revisores possam aceitar/rejeitar edições individuais
- Qualquer cenário onde a substituição direta de texto no
.docxseja insuficiente porque o histórico de alterações é importante
Quando NÃO Usar
- O utilizador quer apenas o texto final substituído (sem marcas de revisão) — use
python-docxdiretamente - O ficheiro é
.doc(formato binário antigo) — não suportado - O utilizador quer editar cabeçalhos/rodapés/notas de rodapé com revisões rastreáveis — esta técnica cobre apenas parágrafos do corpo
Funciona Com
Esta habilidade pode ser usada sozinha ou em conjunto com outras habilidades em office-docx-skills.
- Use com
word-default-formattingquando o texto revisto também deve seguir o perfil de formatação padrão do pacote Word. - Use com
word-formula-writingquando edições relacionadas a fórmulas devem ser representadas com equações editáveis nativas do Word e marcas de revisão visíveis. - Se tanto formatação quanto fórmulas importam, use as três habilidades juntas.
Estrutura de Revisões Rastreáveis OOXML
Um .docx é um zip contendo word/document.xml. Revisões rastreáveis usam dois elementos wrapper em torno de elementos w:r (segmento) dentro de w:p (parágrafo):
<!-- Exclusão: envolve segmentos originais, a tag de texto torna-se w:delText -->
<w:del w:id="102" w:author="Claude" w:date="2026-04-17T10:00:00Z">
<w:r>
<w:rPr><!-- formatação original preservada --></w:rPr>
<w:delText xml:space="preserve">texto antigo aqui</w:delText>
</w:r>
</w:del>
<!-- Inserção: envolve novos segmentos com formatação clonada -->
<w:ins w:id="103" w:author="Claude" w:date="2026-04-17T10:00:00Z">
<w:r>
<w:rPr><!-- clonado do segmento original --></w:rPr>
<w:t xml:space="preserve">novo texto aqui</w:t>
</w:r>
</w:ins>
Regras-chave:
w:delew:inssão filhos diretos dew:p, irmãos dew:pPr- Cada um precisa de um
w:idúnico (inteiro, não deve colidir com IDs existentes no documento) w:authorew:datepreenchem a anotação marginal no Word- Dentro de
w:del, elementos de texto DEVEM serw:delText(nãow:t) - Dentro de
w:ins, elementos de texto são normaisw:t - Propriedades do segmento (
w:rPr) devem ser clonadas dos segmentos originais para preservar fonte/tamanho/negrito
Implementação Central
A implementação reutilizável reside em tracked_change_editor.py junto a esta habilidade. Copie esse helper para o seu diretório de trabalho quando quiser código que pode importar diretamente.
from tracked_change_editor import TrackedChangeEditor
editor = TrackedChangeEditor('input.docx', author='O seu Nome')
for i, p in enumerate(editor.body_paras):
if 'texto alvo' in editor._get_para_text(p):
editor.replace_paragraph_text(i, 'texto substituto')
break
editor.save('output.docx')
tracked_change_editor.py trata dos mecanismos importantes:
- verifica IDs de revisão existentes antes de criar novos nós
w:ins/w:del - preserva a formatação do parágrafo clonando o
w:rPrpadrão do parágrafo - escreve através de um ficheiro temporário seguro antes de mover para o
.docxfinal
Fluxo de Utilização
editor = TrackedChangeEditor('input.docx', author='O seu Nome')
# Encontrar parágrafo alvo pelo conteúdo de texto
for i, p in enumerate(editor.body_paras):
if 'texto alvo' in editor._get_para_text(p):
editor.replace_paragraph_text(i, 'texto substituto')
break
editor.save('output.docx') # Guardar sempre primeiro para um NOVO ficheiro
Armadilhas Críticas — DEVE LER ANTES DE EDITAR
0. TODAS as edições num SCRIPT, ou encadeie a partir do último ficheiro guardado
Se precisar de fazer 7 edições, faça-as TODAS numa única execução de script contra uma única instância de TrackedChangeEditor, depois guarde uma vez. NÃO:
- Execute script A → guarde para
_revised.docx - Execute script B contra o ficheiro ORIGINAL → guarde para
_revised.docx(SOBRESCREVE o trabalho do script A)
Se precisar de dividir em múltiplas execuções de script, cada execução subsequente DEVE carregar a partir da saída anterior:
# Ronda 1
editor = TrackedChangeEditor('original.docx')
# ... edições ...
editor.save('revised.docx')
# Ronda 2 — carregar de revised, NÃO original
editor2 = TrackedChangeEditor('revised.docx')
# ... mais edições ...
editor2.save('revised.docx')
Por que isto importa: Carregar do ficheiro original descarta todas as revisões rastreáveis de rondas anteriores. Este é o erro mais comum — descarta silenciosamente o trabalho sem qualquer aviso.
0b. Inserir novos parágrafos: encontrar a SECÇÃO, não apenas o texto
Ao inserir um novo parágrafo (ex.: uma entrada de referência), NUNCA procure em todo o documento por uma correspondência de texto. As citações no corpo do texto contêm os mesmos nomes de autor que as entradas de referência. Uma pesquisa ingénua como if 'Acharya' in text corresponderá primeiro a um parágrafo do corpo e inserirá a referência no meio da Secção 2.
Abordagem correta — encontrar o limite da secção primeiro:
# Encontrar onde a secção de Referências começa
refs_start_idx = None
for i, p in enumerate(editor.body_paras):
t = editor._get_para_text(p)
if t.strip() == 'Referências':
refs_start_idx = i
break
# Pesquisar APENAS dentro de Referências
for i in range(refs_start_idx + 1, len(editor.body_paras)):
t = editor._get_para_text(editor.body_paras[i])
if t.startswith('Acharya') and 'Johnson' in t:
# Inserir após este parágrafo
break
Por que isto importa: Nomes de autor aparecem tanto em citações no texto quanto em entradas de referência. Sem pesquisa consciente da secção, novos parágrafos de referência acabam no corpo do texto — uma corrupção difícil de detetar até o utilizador abrir o Word.
0c. Reescrever parágrafos em artigos académicos: preservar TODAS as citações
Ao substituir o texto de um parágrafo, DEVE preservar todas as citações (Autor Ano) do original. Artigos académicos têm uma lista de referências que deve corresponder às citações no texto — perder uma citação orfana a entrada de referência ou enfraquece o argumento.
Abordagem correta — extrair antes de reescrever:
import re
texto_antigo = editor._get_para_text(editor.body_paras[idx])
citas_antigas = re.findall(r'\([^)]*\d{4}[a-z]?\)', texto_antigo)
# Escrever novo_texto garantindo que todos os itens em citas_antigas aparecem
# Após escrever, verificar:
for c in citas_antigas:
assert c in novo_texto, f'CITAÇÃO PERDIDA: {c}'
Por que isto importa: Uma introdução reescrita que perde (Malmendier e Nagel 2011; Choi, Gao e Jiang 2020) quebra silenciosamente a integridade das citações do artigo. A lista de referências ainda contém estas entradas, mas nada no texto aponta para elas. Revisores e copidesques irão sinalizar isto.
1. Parágrafos com revisões rastreáveis existentes
Se um parágrafo já contém w:ins/w:del de edições anteriores, _collect_direct_runs apenas captura filhos w:r diretos — segmentos dentro de wrappers w:ins/w:del existentes são ignorados. Isto evita aninhar revisões, mas significa que a substituição cobre apenas a porção "aceite" do texto. Para parágrafos com revisões anteriores extensas, aceite todas as alterações no Word primeiro, depois re-edite.
2. Guardar sempre para um novo ficheiro
Nunca sobrescreva o original. Guarde como _revised.docx, deixe o utilizador verificar no Word, depois substitua manualmente.
3. O índice não será atualizado automaticamente
python-docx / lxml não podem atualizar o Índice do Word. Após editar, o utilizador deve abrir no Word → clique direito no índice → Atualizar Campo.
4. Unicidade do ID de revisão
O método _find_max_id() verifica todos os elementos w:ins, w:del, w:rPrChange, w:pPrChange, w:sectPrChange existentes. Novos IDs incrementam a partir do máximo. Se IDs colidirem, o Word pode corromper o histórico de revisões.
5. Preservação da formatação
_get_default_rpr clona o w:rPr do primeiro segmento no parágrafo. Isto funciona para parágrafos com formatação uniforme. Para parágrafos com formatação mista (ex.: negrito "Palavras-chave:" seguido de texto normal), faça substituição cirúrgica por segmento em vez de substituição de parágrafo inteiro.
6. Fidelidade de ida e volta
Guardar via lxml produz pequenas diferenças cosméticas no XML (estilo de aspas " vs ', finais de linha \r\n vs \n). Conteúdo e formatação são preservados. O tamanho do ficheiro pode diferir devido ao nível de compressão zip — isto é inofensivo.
Lista de Verificação
Após guardar, abra no Word e confirme:
- Texto eliminado mostra riscado vermelho
- Texto inserido mostra sublinhado vermelho
- Nome do autor aparece nas anotações marginais
- Parágrafos circundantes mantêm a formatação original
- Sem marcas de revisão duplicadas ou aninhadas
- Contagem de inserções para o seu autor escolhido corresponde ao esperado — execute um script de verificação para contar
w:inscom o seu nome de autor e imprimir os primeiros 100 caracteres de cada um. Se a contagem for menor que o esperado, edições anteriores foram perdidas (ver Armadilha 0). - Sem entradas de referência no corpo do texto — verifique parágrafos antes do cabeçalho Referências para qualquer parágrafo que pareça uma entrada bibliográfica completa (autor, ano, jornal, volume, páginas). Se encontrado, uma referência foi inserida no local errado (ver Armadilha 0b).
# Snippet de verificação pós-guarda
target_author = 'Codex'
with zipfile.ZipFile('output.docx') as z:
xml = z.read('word/document.xml')
root = etree.fromstring(xml)
author_ins = [e for e in root.findall('.//w:ins', nsmap)
if e.get(qn('w:author')) == target_author]
print(f'{target_author} inserções: {len(author_ins)}')
for i, ins in enumerate(author_ins):
texts = ''.join(t.text for t in ins.findall('.//w:t', nsmap) if t.text)
print(f' [{i+1}] {texts[:100]}')