Node.js: editando .docx para criar modelos
Olá meus Unicórnios! 🦄✨
Sabe aquele pedido que parece bobinho? 😅 "Paloma, o sistema precisa gerar o contrato em Word, já preenchido com os dados do cliente." Pensei: é só trocar um texto por outro, meia horinha.
Aí eu abri o .docx num editor de texto para ver o que tinha lá dentro. E o arquivo respondeu com um punhado de bytes binários. 🙃
Este artigo é o caminho que eu percorri até uma API que gera contratos de verdade, rodando em produção: o que existe dentro de um .docx, como preencher um modelo por código, e — principalmente — as três armadilhas que fizeram meus documentos saírem errados sem ninguém reclamar. Porque o pior defeito desse tipo de código não é o que estoura: é o que sai calado.
📦 A descoberta que muda tudo: um .docx é um zip
Antes de qualquer biblioteca, o fato que reorganiza a cabeça: um arquivo .docx não é um formato binário misterioso. É um arquivo ZIP, com XML dentro. Renomeie para .zip, descompacte, e está tudo lá.
Foi o que eu fiz — por código, listando o conteúdo do template de contrato que uso em produção:
const fs = require('fs');
const PizZip = require('pizzip');
const zip = new PizZip(fs.readFileSync('templates/ContratoServico.docx'));
Object.keys(zip.files).forEach((f) => {
if (!zip.files[f].dir) console.log(f);
});
E o arquivo abriu o jogo:
[Content_Types].xml
_rels/.rels
word/document.xml
word/_rels/document.xml.rels
word/fonts/font1.odttf
word/theme/theme1.xml
word/settings.xml
word/numbering.xml
word/styles.xml
word/fontTable.xml
docProps/core.xml
docProps/app.xml
Repare no word/document.xml: é ali que mora o texto do seu documento. O resto é estilo, fonte, tema, metadados. Todo o conteúdo que o usuário digitou está num XML só.
E qual o tamanho desse XML, num contrato de umas poucas páginas? Eu medi:
tamanho document.xml: 287977 bytes
281 KB de XML para um contrato que, em texto puro, não passaria de 8 KB. 🤯 Esse número é a primeira pista de que editar isso na unha vai doer — e a gente já vai ver exatamente por quê.
🧩 Por que "só trocar o texto" não funciona
Minha primeira ideia foi a mais óbvia do mundo: descompacta, dá um replace no XML trocando {Nome} pelo nome do cliente, compacta de novo. Simples, sem dependência nenhuma.
Essa ideia é uma armadilha, e vale entender o motivo — porque é o mesmo motivo que explica quase todo bug que você vai ter daqui para a frente.
No XML do Word, o texto não fica solto. Ele mora dentro de runs (<w:r>), que são pedaços de texto com formatação uniforme. Se num parágrafo uma palavra está em negrito e o resto não, são dois runs. Até aí, tudo bem.
O problema é que o Word quebra o texto em runs por motivos que não têm nada a ver com formatação. Ele guarda marcas de revisão, de sessão de edição, de verificação ortográfica. Você digita uma palavra, pausa, digita o resto — pode virar dois runs idênticos na aparência.
Quer ver isso acontecendo no meu template real? Este é o trecho de XML onde estão as tags do modelo. Eu digitei {#Empresas} normalmente, como qualquer pessoa digitaria:
<w:r w:rsidR="00A1B2C3" w:rsidRPr="00D4E5F6">
<w:rPr><w:rFonts w:ascii="Arial"/><w:b/><w:sz w:val="22"/></w:rPr>
<w:t>{#</w:t>
</w:r>
<w:r w:rsidR="00A1B2C3" w:rsidRPr="00A1B2C3">
<w:rPr><w:rFonts w:ascii="Arial"/><w:b/><w:sz w:val="22"/></w:rPr>
<w:t>Empresas</w:t>
</w:r>
<w:r w:rsidR="00A1B2C3" w:rsidRPr="00D4E5F6">
<w:rPr><w:rFonts w:ascii="Arial"/><w:b/><w:sz w:val="22"/></w:rPr>
<w:t>}</w:t>
</w:r>
Isso mesmo: a tag {#Empresas} está partida em três runs. 😳 O {# num, o Empresas noutro, o } num terceiro — e os três com exatamente a mesma formatação (mesma fonte, mesmo negrito, mesmo tamanho). Não há razão visual nenhuma para essa divisão. Ela existe porque o Word quis.
Agora encaixe as duas pontas: o seu replace('{#Empresas}', ...) nunca ia achar essa string, porque ela não existe contígua em lugar nenhum do arquivo. Você olharia para o Word vendo {#Empresas} escrito na tela, olharia para o código não encontrando nada, e passaria a tarde achando que enlouqueceu.
É por isso que existe biblioteca para isso. Não é frescura de dependência — é que alguém precisa remontar o texto antes de procurar as tags.
🪄 O docxtemplater e as quatro opções que importam
A biblioteca que resolve isso no mundo Node é o
docxtemplaterdocxtemplater.com. Ele lê o zip (com a ajuda do pizzip), normaliza os runs, encontra as tags e devolve o documento preenchido.
São duas dependências:
npm install docxtemplater pizzip
E o núcleo do meu gerador é este — de verdade, é só isso:
const fs = require('fs');
const PizZip = require('pizzip');
const Docxtemplater = require('docxtemplater');
const content = fs.readFileSync(templatePath, 'binary');
const zip = new PizZip(content);
const doc = new Docxtemplater(zip, {
paragraphLoop: true,
linebreaks: true,
nullGetter: () => '',
});
doc.render(placeholders);
const buffer = doc.getZip().generate({ type: 'nodebuffer' });
fs.writeFileSync(destino, buffer);
Aquelas três opções não são enfeite. Cada uma resolve um problema concreto:
paragraphLoop: true — sem ela, um loop {#Lista}...{/Lista} repete o conteúdo dentro do parágrafo, e você acaba com todos os itens grudados numa linha só. Com ela, o loop repete o parágrafo inteiro. É o que permite ter uma linha em branco entre um item e outro — a linha em branco é um parágrafo vazio dentro do loop, no próprio Word.
linebreaks: true — faz o \n que vem nos seus dados virar quebra de linha de verdade no Word. Sem ela, o \n some e o texto sai emendado.
nullGetter: () => '' — define o que fazer quando o template pede um placeholder que os dados não têm. O padrão do docxtemplater é escrever undefined no documento. 😱 Imagine um contrato com undefined no lugar do CPF, indo para a assinatura do cliente. Com essa linha, o campo ausente vira string vazia.
Segure essa terceira opção, porque ela é ao mesmo tempo a salvação e a armadilha número um deste artigo. Já volto nela.
🏗️ A arquitetura que evita o "if" gigante
Com o mecanismo funcionando, veio a pergunta de organização: eu tenho vários tipos de contrato, cada um com um .docx diferente. Como não transformar isso num switch monstruoso?
A separação que adotei tem duas peças, e ela se paga rápido:
1. Um registry — o mapa que liga o nome do modelo ao arquivo e à função que prepara os dados:
module.exports = {
// Varios .docx: o arquivo sai do codigo do produto
ContratoLocacao: {
templates: {
'7': 'ContratoLocacao_7.docx',
'8': 'ContratoLocacao_8.docx',
},
formatter: require('./ContratoLocacao'),
},
// Um .docx so
ContratoServico: {
template: 'ContratoServico.docx',
formatter: require('./ContratoServico'),
},
};
Repare que existem duas formas de apontar o arquivo, e elas são exclusivas: template (um .docx fixo) ou templates (um mapa, onde o arquivo é escolhido por um código que vem nos dados). Isso apareceu porque o mesmo tipo de contrato tinha redações diferentes por produto — e eu não queria um formatter por redação, já que os dados são os mesmos; só o texto ao redor muda.
2. Um formatter por modelo — a função que transforma os dados crus da API nos placeholders que o template consome. É aqui que mora toda a regra de negócio: máscara de CPF, data em português, campos opcionais.
A resolução do template virou uma função pequena e testável:
function resolverTemplate(modelo, entry, dados) {
// Um .docx fixo? Acabou aqui.
if (typeof entry.template === 'string') return entry.template;
const codigo = String(dados.Contrato.CodigoProduto || '').trim();
// hasOwnProperty: sem ele, um codigo 'constructor' acharia algo
if (codigo && Object.prototype.hasOwnProperty.call(entry.templates, codigo)) {
return entry.templates[codigo];
}
throw new CodigoProdutoInvalidoError(modelo, codigo);
}
(Enxuguei as checagens defensivas para caber na tela — no código real ainda há uma guarda para a entry mal configurada, sem template nem templates.)
Duas decisões nesse trecho que parecem exagero e não são:
O hasOwnProperty.call em vez de um simples entry.templates[codigo]: sem ele, um código de produto chamado "constructor" ou "toString" acharia alguma coisa — as propriedades herdadas de Object — e o código seguiria adiante com lixo no lugar do nome do arquivo. É o tipo de coisa que ninguém testa e que um dia chega pela API.
E o throw em vez de um return null: o erro tipado sobe até a rota, que traduz em HTTP. Assim o gerador.js não conhece HTTP e a rota não conhece regra de template. Cada camada fala a própria língua.
✍️ Negrito no meio da frase: o truque do Nome e do Resto
Aqui está o problema que mais me fez pensar, e a solução que mais me agradou.
O contrato precisa de uma frase assim, com só o nome em negrito e o resto normal:
PAPELARIA ARCO-IRIS LTDA, inscrita no CNPJ nº 11.222.333/0001-81,
com sede na Avenida das Estrelas, nº 1000, Centro,
Cep 01310-100, Cidade Exemplo - SP, neste ato representada por sua
responsável legal: Fulana de Tal, portadora do CPF nº 123.456.789-09,
nascida em 01/01/1990, e-mail: [email protected].
A tentação é montar a frase inteira no código com HTML ou marcação de negrito e jogar tudo num placeholder só. Não funciona — o docxtemplater escreve texto, não formatação. Um <b> sairia literal no documento.
A saída é inverter a responsabilidade: a formatação é do template, os dados são do código. Então o formatter devolve a frase partida em dois:
{
"Empresas": [
{
"Nome": "PAPELARIA ARCO-IRIS LTDA",
"Resto": ", inscrita no CNPJ nº 11.222.333/0001-81, com sede na Avenida das Estrelas, nº 1000, Centro, Cep 01310-100, Cidade Exemplo - SP, neste ato representada por sua responsável legal: Fulana de Tal, portadora do CPF nº 123.456.789-09, nascida em 01/01/1990, e-mail: [email protected]."
}
]
}
E no Word, o template tem {Nome} em negrito e {Resto} em texto normal, colados um no outro. O docxtemplater preserva a formatação de cada run — então o nome sai negrito e o resto não, sem o código saber o que é negrito.
Repare no detalhe que faz a costura funcionar: o Resto começa com ", ". A vírgula e o espaço fazem parte do texto normal, não do nome — senão a vírgula sairia em negrito também. É um detalhe de dois caracteres que decide se a frase parece profissional ou remendada.
No template Word, a estrutura fica assim:
{#Empresas}
**{Nome}**{Resto}
{/Empresas}
Onde ** significa "aplique negrito de verdade no Word", não digite asteriscos. 😄 E aquele parágrafo vazio antes do {/Empresas} é o que cria a linha em branco entre um item e outro quando a lista tem vários — é estrutura do documento, não do código.
O formatter, por sua vez, cuida só de língua portuguesa e de campos opcionais:
function responsavel(imob) {
const nome = imob.Responsavel_Nome && imob.Responsavel_Nome.trim();
if (!nome) return [];
const partes = [`neste ato representada por seu responsável legal: ${nome}`];
if (imob.Responsavel_CPF && imob.Responsavel_CPF.trim()) {
partes.push(`portadora do CPF nº ${maskCpf(imob.Responsavel_CPF)}`);
}
const data = formatDateBr(imob.Responsavel_DataNascimento);
if (data) partes.push(`nascida em ${data}`);
if (imob.Responsavel_Email && imob.Responsavel_Email.trim()) {
partes.push(`e-mail: ${imob.Responsavel_Email.trim()}`);
}
return partes;
}
Note o padrão: cada pedaço é um item de array, e no fim tudo é unido com vírgula. Nada de concatenar string com if no meio, porque aí você acaba com vírgula dobrada ou vírgula sobrando no fim quando um campo é opcional. Juntar no final resolve isso de graça:
const texto = partes.filter((p) => p && String(p).trim()).join(', ');
return `, ${texto}.`;
🔥 Três mentiras que o meu documento me contou
Se você só for ler um pedaço deste artigo, leia este. 🙏 O que vem aqui é o que me custou tempo de verdade — e o que nenhuma documentação conta, porque documentação descreve o caminho feliz.
Todas as três eu reproduzi por código para escrever esta seção. As saídas abaixo são reais.
Mentira nº 1: "o placeholder errado vai dar erro"
Lembra do nullGetter: () => '', que eu chamei de salvação? Ele é. Mas ele também é a coisa mais perigosa da configuração, e eu levei um tempo para entender por quê.
Ele diz: "placeholder que não existe nos dados vira string vazia". Perfeito para o campo opcional que ficou em branco. Idêntico ao que acontece quando você digita o nome do campo errado no Word.
Eu montei o teste — um template com um placeholder certo e um com nome errado:
const doc = criarDoc('Nome: {Nome} | Digitado errado: {NomeCompleto}');
doc.render({ Nome: 'Paloma' });
console.log(JSON.stringify(doc.getFullText()));
Resultado:
"Nome: Paloma | Digitado errado: "
Zero erro. Zero aviso. Zero exceção. 😨 O documento é gerado, a API responde sucesso: true, o cliente recebe o link, baixa o contrato — e no lugar do dado tem um buraco. Ninguém percebe até alguém ler o papel.
Esse é o defeito mais caro desse tipo de sistema, porque ele não falha. Ele entrega errado com cara de certo.
O que eu faço para me proteger: teste que lê o texto do documento gerado e afirma que o dado está lá. Não basta testar que o formatter devolveu o objeto certo — é preciso abrir o .docx de saída e conferir:
const buf = fs.readFileSync(path.join(config.outputDir, nome));
const zip = new PizZip(buf);
const doc = new Docxtemplater(zip);
const texto = doc.getFullText();
expect(texto).toContain('Fulano');
expect(texto).toContain('CPF nº 123.456.789-09');
Aquele getFullText() é a ferramenta mais útil da biblioteca para quem escreve teste. Ele devolve o texto puro do documento — dá para afirmar sobre o resultado sem abrir o Word.
Mentira nº 2: "espaço não faz diferença"
Essa me pegou de jeito, e é irmã da primeira. Quem digita a tag no Word é uma pessoa, e pessoas colocam espaço para respirar:
const doc = criarDoc('[{ Nome }]');
doc.render({ Nome: 'Paloma' });
console.log(JSON.stringify(doc.getFullText()));
Saída real:
"[]"
Sumiu. 😳 { Nome } com espaços não é {Nome} — é um campo chamado " Nome ", que não existe nos dados. E aí o nullGetter faz o serviço dele e apaga tudo, caladinho.
É por isso que essas duas mentiras andam juntas: a nº 2 cria o erro, a nº 1 esconde o erro. Um espaço invisível vira um campo em branco no contrato.
Mentira nº 3: "tag partida entre runs quebra o template"
Essa é a mais interessante, porque eu estava errada. 😅
Eu tinha isso anotado como regra no projeto: "cuidado com fragmentação de tags — o Word quebra {Nome} em vários runs e o placeholder deixa de funcionar; digite a tag de uma vez". Eu repetia esse conselho com confiança.
Quando fui reproduzir para escrever o artigo, montei três documentos: a tag inteira num run, a tag partida em três runs, e a tag partida entre dois parágrafos diferentes. Esperava ver dois erros.
A tag inteira => OK : "Ola Paloma!"
B partida em 3 runs => OK : "Ola Paloma!"
C partida em 2 paragrafos => OK : "Ola Paloma!"
Os três funcionaram. 🤯 Inclusive o caso partido entre parágrafos, que eu tinha certeza que quebraria.
Fui atrás do porquê, e a resposta estava no meu próprio template — aquele XML com {#, Empresas e } em três runs separados que eu mostrei lá em cima. Ele está em produção. Ele funciona. Se a fragmentação quebrasse mesmo, meu contrato nunca teria sido gerado uma vez sequer.
O que acontece é que o docxtemplater junta os runs antes de procurar as tags — é exatamente o serviço que ele presta, o mesmo que faltaria no replace caseiro do começo do artigo. Versões antigas sofriam com isso; a v3 resolve.
Então o conselho "digite a tag de uma vez" continua bom — higiene de template é sempre bem-vinda —, mas a justificativa que eu dava estava desatualizada. Eu tinha portado um aviso antigo sem conferir se ainda valia. E fica a lição, que serve para além do Word: aviso herdado tem prazo de validade. 🙈
O que quebra de verdade (e faz barulho)
Para fechar honestamente: existem sim erros que o docxtemplater grita. E são justamente os estruturais:
chave sozinha (sem fechar) => ERRO : The tag beginning with "{Nome!" is unclosed
loop nao fechado => ERRO : The loop with tag "Lista" is unclosed
Esses são os bons erros: estouram na hora de montar o documento, com o nome da tag e o arquivo. Um { aberto e não fechado, ou um {#Lista} sem o {/Lista}, param tudo.
Repare no contraste que resume a seção inteira: errar a estrutura da tag estoura; errar o nome da tag passa batido. O primeiro caso o teste pega sozinho. O segundo, só se você for atrás.
E vale um alerta extra sobre autocorreção: se o Word transformar suas aspas ou chaves em versões "inteligentes" tipográficas, o resultado cai no caso silencioso — o documento gera, o campo some. Na dúvida, desligue a autocorreção antes de editar template.
📛 O nome do arquivo gerado (que não é detalhe)
Uma coisinha que parece burocracia e evita dor de cabeça: o nome do arquivo de saída. Quando dois pedidos chegam no mesmo segundo, um não pode sobrescrever o outro; e quando o cliente liga reclamando, você precisa achar o arquivo dele.
function montarNomeArquivo(templateNome, dados) {
const bruto = String(dados.Contrato.Codigo || '').trim();
const codigo = bruto ? sanitizarParte(bruto) : 'sem-codigo'; // para achar
const modelo = sanitizarParte(templateNome.replace(/\.docx$/i, '')); // qual gerou
const dataHora = carimboDeTempo(); // para ordenar
const sufixo = uuidv4().replace(/-/g, '').slice(0, 8); // para nao colidir
return `${codigo}_${modelo}_${dataHora}_${sufixo}.docx`;
}
E o carimbo de tempo, que é só formatação:
function carimboDeTempo(d = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
+ `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
Quatro partes, cada uma com uma função: o código do contrato (para achar), o nome do template (para saber qual modelo gerou), a data e hora (para ordenar) e 8 caracteres aleatórios (para não colidir). O resultado fica assim:
29660_ContratoServico_20260805_143012_a3f9c1d8.docx
A parte que eu quase esqueci foi a sanitizarParte:
function sanitizarParte(valor) {
return String(valor).replace(/[^A-Za-z0-9._-]/g, '-');
}
Ela não é firula. Aquele Codigo vem de fora, pela API, e vira duas coisas perigosas ao mesmo tempo: um caminho no sistema de arquivos e um componente de URL. Um código com ../ escreveria fora da pasta de saída. Whitelist de caracteres — o que não estiver na lista vira hífen — é a defesa mais simples que existe aqui.
🧪 Testando sem depender do template do cliente
Último problema prático, e ele é chato: os templates .docx não estão no Git. São documentos do cliente, com redação jurídica, que mudam sem me avisar. Versionar isso não faz sentido.
Só que aí os testes não rodam numa máquina limpa nem no CI, porque a pasta templates/ chega vazia. 😩
A saída foi gerar um .docx mínimo em memória na hora do teste. Lembra que .docx é só um zip com XML? Então dá para montar um do zero, com três arquivos:
function buildTemplate(dest) {
const zip = new PizZip();
zip.file('[Content_Types].xml', contentTypes); // o que tem no pacote
zip.file('_rels/.rels', rels); // quem e o documento
zip.file('word/document.xml', documentXml); // o conteudo
fs.writeFileSync(dest, zip.generate({ type: 'nodebuffer' }));
}
Três arquivos e o documento é válido: o [Content_Types].xml (diz que tipo de coisa tem no pacote), o _rels/.rels (aponta qual XML é o documento principal) e o word/document.xml (o conteúdo). Só isso já abre no Word.
E o documentXml é onde mora a especificação do modelo — um parágrafo por linha, com o negrito no {Nome}:
<w:body>
<w:p><w:r><w:t>{#Inquilinos}</w:t></w:r></w:p>
<w:p>
<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">{Nome}</w:t></w:r>
<w:r><w:t xml:space="preserve">{Resto}</w:t></w:r>
</w:p>
<w:p/> <!-- linha em branco entre itens -->
<w:p><w:r><w:t>{/Inquilinos}</w:t></w:r></w:p>
</w:body>
E olha o xml:space="preserve" ali nos <w:t> — sem ele o XML come os espaços das pontas, e o {Nome} gruda no {Resto} perdendo a separação. Um atributo, um bug de espaçamento inteiro.
O melhor dessa abordagem não é nem rodar sem o template real. É que o stub documenta o formato esperado: quem quiser saber como o template do cliente deve estar montado — negrito no {Nome}, parágrafo vazio no loop — lê esse arquivo. Vira especificação executável.
E com o negrito no stub, dá para testar o que parecia impossível de testar: que o nome saiu em negrito de verdade. Basta olhar o XML gerado, achar o texto e conferir o <w:rPr> imediatamente anterior:
const xml = zip.file('word/document.xml').asText();
const idx = xml.indexOf('Fulano');
const rPrStart = xml.lastIndexOf('<w:rPr>', idx);
const rPrEnd = xml.indexOf('</w:rPr>', rPrStart);
expect(xml.slice(rPrStart, rPrEnd)).toContain('<w:b/>');
É um teste que olha para dentro do arquivo binário e afirma sobre formatação. Feio? Um pouco. 😄 Mas é o único jeito de garantir, sem abrir o Word na mão, que a regra do negrito continua valendo depois de qualquer refatoração.
📋 O resumo que eu queria ter lido antes
Se eu pudesse mandar um bilhete para a Paloma de antes de começar, seria este:
- O
.docxé um zip com XML. Abra e olhe — entender isso resolve metade das dúvidas. - Não faça
replaceno XML. O Word parte suas tags em pedaços; o meu template real tem{#Empresas}em três runs. - Formatação é do template, dado é do código. Para negrito no meio da frase, parta em
{Nome}+{Resto}— e comece oRestocom", ". nullGettersalva doundefinede esconde o campo errado. Aceite os dois lados e compense com teste.- Teste lendo o documento gerado, não só o objeto do formatter.
getFullText()é seu amigo. - Sanitize o que vira nome de arquivo. Ele é caminho e URL ao mesmo tempo.
- Monte um
.docxmínimo nos testes. Três arquivos, e você fica livre do template do cliente.
E a lição que eu levo para fora do Word: o erro que mais me custou não foi o que estourou na tela. Foi o campo que sumiu em silêncio, com a API respondendo sucesso: true e todo mundo achando que estava tudo bem. 😅 Quando um sistema tem um caminho para "não achei o dado, deixa em branco", alguém precisa ficar vigiando esse caminho — porque ele não vai reclamar sozinho.
💾 O repositório
Destilei tudo num exemplo que roda sozinho — com dados fictícios e o modelo montado do zero, sem depender de nenhum .docx de cliente. O armadilhas.js reproduz as três mentiras desta seção em um comando:
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.