Gerando a DANFE do XML da nota com Node.js
Olá meus Unicórnios! 🦄✨
Todo mundo que já mexeu com nota fiscal eletrônica conhece aquela folha feia, cheia de retângulos, com um código de barras gordo no canto: a DANFE. Ela não é a nota. A nota é o XML assinado que está lá na Sefaz; a DANFE é só o papel que acompanha a mercadoria para o fiscal poder conferir na estrada.
E aí vem o pedido: "preciso que o sistema gere a DANFE quando o cliente clicar em imprimir". Sabe quando você acha que vai ser meia hora de trabalho porque com certeza existe uma biblioteca pronta? 😅 Pois é. Existe biblioteca para ler o XML. O desenho da folha é seu.
Passei por isso e a solução que ficou de pé é bem menos assustadora do que parece: ler o XML com uma biblioteca, montar o HTML da folha na mão e mandar o Chrome imprimir esse HTML em PDF. Três passos, um arquivo só. Vamos juntos? 💜
🧩 As duas peças que você vai instalar
Uma lê, a outra imprime:
npm install djf-nfe puppeteer
O djf-nfe abre o XML da NF-e e te devolve um objeto onde cada campo é um método: nfe.chave(), nfe.emitente().nome(), e por aí. Ele poupa o trabalho de caçar tag por tag num XML com quatro níveis de aninhamento.
O puppeteer é o Chrome sem janela, controlado por código. Ele existe aqui por um motivo só: saber transformar HTML em PDF A4 respeitando milímetro, borda fina e quebra de página. Na instalação ele baixa uma cópia própria do Chrome, então prepare-se para esperar alguns minutos e uns bons megabytes.
📖 Lendo o XML sem quebrar na primeira nota diferente
Aqui está a primeira armadilha, e ela morde cedo. Nem toda nota tem todos os grupos. Uma venda retirada no balcão não tem transportador. Uma nota à vista não tem duplicata. Um produto isento não tem IPI.
Se você escrever nfe.transportador().nome() e a nota não tiver transportador, o programa morre com um TypeError. Então a primeira função do arquivo é um ajudante que pergunta antes de chamar:
// Chama um metodo do djf-nfe sem derrubar o programa quando o grupo
// nao existe no XML (nota sem transportador, sem duplicata, sem IPI...).
function ler(objeto, metodo) {
if (!objeto) {
return "";
}
if (typeof objeto[metodo] !== "function") {
return "";
}
try {
const valor = objeto[metodo]();
if (valor === null || valor === undefined) {
return "";
}
return valor;
} catch (erro) {
return "";
}
}
Parece defensivo demais para uma função de oito linhas? É a função mais usada do arquivo inteiro. Todo campo que sai do XML passa por ela, e o resultado é que uma nota sem transportador imprime o bloco do transportador em branco, como manda o figurino, em vez de estourar.
Com ela na mão, a leitura dos dados fica direta. Primeiro as conferências que decidem se vale a pena continuar:
function extrairDados(xml) {
const nfe = lerNfe(xml);
if (!nfe || typeof nfe.chave !== "function") {
throw new Error("O arquivo nao e um XML de NF-e.");
}
const chave = ler(nfe, "chave");
if (!chave) {
throw new Error("XML sem chave de acesso: nao e uma NF-e.");
}
const modelo = String(ler(nfe, "modelo"));
if (modelo !== "" && modelo !== "55") {
throw new Error("Modelo " + modelo + " nao suportado. O DANFE e do modelo 55.");
}
A checagem do modelo evita uma confusão clássica. O modelo 55 é a NF-e, a de mercadoria, que gera DANFE. O modelo 65 é a NFC-e, a do cupom de supermercado, que gera um cupom estreito com QR Code, layout completamente diferente. Alimentar uma NFC-e nesse código produziria uma folha A4 torta e sem sentido: melhor recusar com uma frase clara.
🚚 A placa não está onde você vai procurar
Essa me custou um tempo bobo. Dentro de <transp> existem dois grupos irmãos, e eles guardam coisas diferentes:
<transp>
<modFrete>1</modFrete>
<transporta> <- nome, CNPJ, inscricao estadual, endereco
...
</transporta>
<veicTransp> <- placa, UF da placa, codigo ANTT
...
</veicTransp>
</transp>
Repare no detalhe cruel: a razão social da transportadora e a placa do caminhão aparecem lado a lado na mesma linha do DANFE impresso. Olhando a folha, parece óbvio que os dois vêm do mesmo lugar. Não vêm. Pedir a placa ao transportador devolve vazio, sem erro nenhum, e o campo simplesmente sai em branco no papel.
const transporte = ler(nfe, "transporte");
const transportador = ler(nfe, "transportador");
// Placa e UF da placa moram em <veicTransp>, e nao em <transporta>.
// Procurar a placa no transportador devolve vazio e ninguem repara.
const veiculo = ler(transporte, "veiculo");
1️⃣ Os itens começam no um, não no zero
Essa é a que mais me irrita, porque contraria tudo que a gente aprende em programação. O djf-nfe numera os produtos igual à Sefaz numera: de 1 até nrItens().
const itens = [];
const quantosItens = ler(nfe, "nrItens") || 0;
// Os itens sao numerados de 1 ate nrItens. Comecar do zero devolve
// undefined e o primeiro produto some do DANFE.
for (let i = 1; i <= quantosItens; i++) {
const item = nfe.item(i);
if (!item) {
continue;
}
Se você escrever o for do jeito automático, começando em 0 e indo até < quantosItens, acontece o pior tipo de bug: o programa não reclama. Ele chama nfe.item(0), recebe undefined, pula com o continue, e para uma linha antes do último produto. Na nota de dois itens você imprime um. Na de dez você imprime nove, e os totais não batem com a soma da tabela. 😳
Dentro do laço mora outra sutileza, essa do mundo fiscal:
const imposto = ler(item, "imposto");
const icms = ler(imposto, "icms");
// O CST impresso e a origem colada no CST. Empresa do Simples
// nao tem CST, tem CSOSN: se um estiver vazio, use o outro.
const origem = String(ler(icms, "origem"));
let situacao = String(ler(icms, "cst"));
if (situacao === "") {
situacao = String(ler(icms, "csosn"));
}
A coluna CST do DANFE não imprime só o CST. Ela imprime a origem da mercadoria (0 para nacional, 1 para importada direta, e assim por diante) grudada no código de situação tributária. Por isso aparece 000 numa nota comum: origem 0 mais CST 00.
E empresa do Simples Nacional não emite CST, emite CSOSN, que é outra tag. Como quase toda loja pequena é do Simples, ignorar o CSOSN deixa a coluna vazia justamente nos clientes mais comuns.
🔢 O código de barras da chave, desenhado na unha
Agora a parte que parece impossível e não é. O DANFE exige a chave de acesso, aqueles 44 dígitos, impressa em Code128C. Minha primeira reação foi procurar uma fonte de código de barras para instalar no servidor. Péssima ideia: fonte depende do sistema, some em container, e o Chrome pode renderizar diferente.
O Code128C é, no fundo, uma tabela. Cada símbolo vira seis larguras alternando barra e espaço, e a tabela tem 107 entradas fixas:
// Cada simbolo do Code128 tem 6 larguras: barra, espaco, barra, espaco,
// barra, espaco. A tabela abaixo e a da especificacao, na ordem dos valores.
const PADROES = [
"212222", "222122", "222221", "121223", "121322", "131222", "122213", "122312",
"132212", "221213", "221312", "231212", "112232", "122132", "122231", "113222",
// ... 107 entradas no total
"211214", "211232", "233111"
];
O "C" do Code128C quer dizer que ele codifica os dígitos de dois em dois, o que economiza metade das barras. E aí cai bem: a chave tem 44 dígitos, que é par. Dá para usar o conjunto C do começo ao fim, sem trocar de conjunto no meio.
function gerarCodigoBarras(chave, largura, altura) {
const digitos = String(chave).replace(/\D/g, "");
// O Code128C codifica os digitos de dois em dois. A chave tem 44,
// entao sempre fecha; um tamanho impar nao seria representavel.
if (digitos.length === 0 || digitos.length % 2 !== 0) {
return "";
}
const simbolos = [105]; // 105 = Start Code C
let soma = 105;
let posicao = 1;
for (let i = 0; i < digitos.length; i += 2) {
const valor = parseInt(digitos.substr(i, 2), 10);
simbolos.push(valor);
soma += valor * posicao;
posicao = posicao + 1;
}
simbolos.push(soma % 103); // digito verificador
simbolos.push(106); // 106 = Stop
Olha que elegante: o par 35 vira o símbolo de valor 35, o par 26 vira o de valor 26. O dígito verificador é a soma de cada valor multiplicado pela sua posição, tudo módulo 103. É aritmética de escola.
Depois cada símbolo vira retângulos num SVG. E tem um detalhe aqui que não é enfeite:
// A margem branca de 10 modulos em cada lado e obrigatoria: sem ela o
// leitor otico nao acha onde o codigo comeca e o DANFE nao e lido.
const MARGEM = 10;
const escala = largura / (totalModulos + MARGEM * 2);
let retangulos = "";
let x = MARGEM * escala;
for (let i = 0; i < modulos.length; i++) {
const w = modulos[i].largura * escala;
if (modulos[i].ehBarra) {
retangulos = retangulos +
'<rect x="' + x.toFixed(3) + '" y="0" width="' + w.toFixed(3) +
'" height="' + altura + '" fill="#000"/>';
}
x = x + w;
}
Aquela MARGEM de 10 módulos é a zona de silêncio da especificação. Sem ela o código fica bonito na tela e o leitor do fiscal não consegue delimitar onde ele começa. É o tipo de erro que só aparece quando alguém tenta bipar o papel de verdade, e aí é tarde. 🙈
Como o resultado é um SVG, ele vai dentro do HTML, e o Chrome imprime como vetor no PDF. Nenhuma fonte instalada, nenhuma imagem gerada em disco.
🧱 Montando o HTML da folha
O DANFE é repetitivo: dezenas de caixinhas com um rótulo miúdo em cima e o valor embaixo. Em vez de escrever essa div quarenta vezes, uma função pequena resolve:
function montarCampo(largura, rotulo, valor, alinhar) {
let classe = "cel";
if (alinhar === "d") {
classe = "cel d";
}
return "<div class='" + classe + "' style='width:" + largura + "%'>" +
"<span class='rot'>" + rotulo + "</span>" +
"<span class='val'>" + valor + "</span></div>";
}
E cada linha do documento vira uma sequência legível de chamadas, com as larguras somando 100:
"<div class='linha topo'>" +
montarCampo(34, "MUNICIPIO", escapar(dados.destinatario.municipio)) +
montarCampo(16, "FONE", escapar(dados.destinatario.telefone)) +
montarCampo(6, "UF", escapar(dados.destinatario.uf)) +
montarCampo(26, "INSCRICAO ESTADUAL", escapar(dados.destinatario.ie)) +
montarCampo(18, "HORA DE SAIDA", escapar(dados.horaSaida)) +
"</div>"
Repare no escapar() em volta de tudo. Ele não é paranoia:
// Escapa o que vai para o HTML. O XML vem de fora e pode trazer & ou <,
// que sem escape quebrariam o documento no meio de um campo.
function escapar(valor) {
return String(valor)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
O XML da nota veio do sistema de outra empresa. Basta um produto chamado PARAFUSO 1/2" < 3MM para o seu HTML virar sopa e metade da folha sumir. Já vi acontecer com uma razão social contendo &, que é comum em nome de empresa.
💧 A marca d'água que precisa existir
Quando tpAmb vale 2, a nota está no ambiente de homologação: ela existe, tem chave, tem protocolo, e não vale absolutamente nada. Se o seu DANFE de homologação sair idêntico ao de produção, você acabou de fabricar um documento falso muito convincente.
// tpAmb igual a 2 e homologacao: a nota nao vale nada e o DANFE
// precisa dizer isso, senao vira um documento falso convincente.
ehHomologacao: (String(ler(nfe, "tipoAmbiente")) === "2"),
No HTML ela é uma div posicionada por cima de tudo, girada e bem clarinha:
".marca { position: absolute; top: 38%; left: 8%; right: 8%; text-align: center;" +
" font-size: 30pt; color: rgba(0,0,0,0.12); transform: rotate(-18deg); font-weight: bold; }"
🖨️ Do HTML para o PDF
A última etapa é curta, e é onde mora a armadilha que mais me pegou:
async function gerarPdf(html, caminhoPdf) {
const navegador = await puppeteer.launch();
try {
const pagina = await navegador.newPage();
// setContent recebe o HTML direto, sem servidor nem arquivo na frente.
await pagina.setContent(html, { waitUntil: "load" });
await pagina.pdf({
path: caminhoPdf,
format: "A4",
// Sem printBackground o Chrome descarta fundos e a marca d'agua
// de homologacao nao sai no PDF.
printBackground: true,
margin: { top: "4mm", bottom: "4mm", left: "4mm", right: "4mm" }
});
} finally {
// O fechamento vai no finally: se a geracao falhar no meio, sem isso
// o Chrome fica rodando e o processo do Node nunca termina.
await navegador.close();
}
}
Duas linhas merecem atenção, e as duas evitam bug chato:
O printBackground: true. Por padrão o Chrome imprime como as impressoras domésticas gostam: jogando fora fundos e sombras para poupar tinta. Só que a nossa marca d'água de homologação é um fundo. Sem essa opção, ela aparece perfeita na tela e desaparece no PDF, que é justamente o arquivo que alguém vai imprimir e levar na rua.
O close() dentro do finally. Esse eu aprendi apanhando. Se a geração falhar no meio (um HTML mal formado, o disco cheio na hora de salvar), o await lança e o navegador.close() escrito logo abaixo nunca roda. O Chrome fica vivo em segundo plano, o Node não encerra porque ainda tem um processo filho preso, e o servidor vai acumulando Chromes zumbis até acabar a memória. O finally fecha o navegador dando certo ou dando errado.
E o setContent é a razão de tudo isso ser simples: ele aceita a string do HTML direto, sem você precisar subir um servidor ou gravar um arquivo temporário para depois abrir por file://.
🎯 Juntando tudo num programa só
O fim do arquivo é o que amarra as três etapas, com o tratamento de erro do jeito óbvio:
async function principal() {
const caminhoXml = process.argv[2];
const caminhoPdf = process.argv[3] || "danfe.pdf";
if (!caminhoXml) {
console.log("Uso: node danfe.js nota.xml danfe.pdf");
process.exit(1);
}
try {
const xml = fs.readFileSync(caminhoXml, "utf8");
const dados = extrairDados(xml);
const html = montarHtml(dados);
await gerarPdf(html, caminhoPdf);
console.log("Nota: " + dados.numero + " / serie " + dados.serie);
console.log("Chave: " + dados.chave);
console.log("Emitente: " + dados.emitente.nome);
console.log("Itens: " + dados.itens.length);
console.log("Total: R$ " + dinheiro(dados.totais.valorNota, 2));
console.log("PDF: " + caminhoPdf);
} catch (erro) {
console.error("Falha ao gerar o DANFE: " + erro.message);
process.exit(1);
}
}
principal();
Rodando com um XML de exemplo, com dados fictícios, a saída é essa:
$ node danfe.js nota.xml danfe.pdf
Nota: 123 / serie 1
Chave: 35260911111111000191550010000001231000001238
Emitente: PAPELARIA ARCO-IRIS LTDA
Itens: 2
Total: R$ 300,00
PDF: danfe.pdf
E a folha que sai do outro lado:

Dá para ver tudo funcionando de uma vez: o código de barras montado a partir dos 44 dígitos, a chave agrupada de quatro em quatro logo abaixo, os dois produtos com a coluna CST mostrando 000, e o SEM VALOR FISCAL atravessado avisando que aquela nota é de homologação.
💥 E quando o XML não presta?
Vale testar o caminho da falha, porque é o que mais acontece na vida real: o usuário sobe o arquivo errado, ou o download veio pela metade e o XML está truncado.
$ node danfe.js ruim.xml saida.pdf
Falha ao gerar o DANFE: XML sem chave de acesso: nao e uma NF-e.
$ echo $?
1
Repare que ele não estoura com stack trace: diz em português o que houve e sai com código 1. Isso importa mais do que parece. Se amanhã esse script virar um passo de um agendamento, o 1 é o que faz o agendador perceber que deu errado, em vez de seguir feliz achando que gerou o PDF.
🗺️ O mapa dos campos, em português
Para quando você for atrás de um campo que este artigo não citou, aqui está a tradução do que vem do XML:
Grupo do XML Onde fica O que guarda
------------------ --------------------------- ----------------------------
ide nfe.nrNota(), serie() numero, serie, natureza
emit nfe.emitente() quem vende, com endereco
dest nfe.destinatario() quem compra, com endereco
det (por item) nfe.item(1) ate item(n) produto, NCM, CFOP, impostos
total nfe.total() os valores do rodape
transp nfe.transporte() modalidade do frete, volume
transporta nfe.transportador() razao social e CNPJ
veicTransp transporte.veiculo() placa, UF da placa, ANTT
cobr/dup nfe.duplicata(1) ate (n) numero, vencimento, valor
infAdic nfe.informacoesComplementares() as observacoes
Na especificação da NF-e esses grupos aparecem pelos nomes originais, que são os da coluna da esquerda: ide, emit, dest, det, total, transp, cobr e infAdic. Se você precisar consultar o manual da Sefaz, é por eles que a busca funciona.
🧪 Uma nota de exemplo para começar
Ninguém tem um XML de NF-e à mão no primeiro dia. Este pedacinho, com CNPJ e nomes claramente inventados, é suficiente para o script rodar de ponta a ponta:
<?xml version="1.0" encoding="UTF-8"?>
<nfeProc versao="4.00" xmlns="http://www.portalfiscal.inf.br/nfe">
<NFe>
<infNFe versao="4.00" Id="NFe35260911111111000191550010000001231000001238">
<ide>
<natOp>VENDA DE MERCADORIA</natOp>
<mod>55</mod>
<serie>1</serie>
<nNF>123</nNF>
<dhEmi>2026-03-10T14:30:00-03:00</dhEmi>
<tpNF>1</tpNF>
<tpAmb>2</tpAmb>
</ide>
<emit>
<CNPJ>11111111000191</CNPJ>
<xNome>PAPELARIA ARCO-IRIS LTDA</xNome>
<enderEmit>
<xLgr>RUA DAS ESTRELAS</xLgr>
<nro>100</nro>
<xBairro>CENTRO</xBairro>
<xMun>SAO PAULO</xMun>
<UF>SP</UF>
<CEP>01001000</CEP>
</enderEmit>
<IE>111111111111</IE>
</emit>
...
</nfeProc>
Um aviso importante: use sempre dados inventados nos seus testes. XML de nota real carrega CNPJ, endereço e às vezes CPF de pessoa física do destinatário. Esse arquivo não pode virar exemplo em repositório, anexo de chamado ou print em grupo de trabalho. 🔐
📆 A data que volta um dia
Para fechar, uma armadilha pequena que dá um bug difícil de acreditar. A data vem assim no XML:
<dhEmi>2026-03-10T14:30:00-03:00</dhEmi>
A tentação é fazer new Date(valor) e formatar. O problema é que esse texto já traz o fuso dele (-03:00), e o Date reinterpreta aquele instante pelo fuso da máquina onde o código roda. Num servidor em UTC, uma nota emitida às 22h de terça vira quarta-feira no papel. E ninguém desconfia até o contador ligar perguntando por que a data do DANFE não bate com a da nota.
// "2026-03-10T14:30:00-03:00" vira "10/03/2026".
// Nao use new Date(): a string tem fuso proprio e o Date reinterpreta,
// fazendo a nota emitida as 00:30 aparecer no dia anterior.
function formatarData(valor) {
const achou = String(valor).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!achou) {
return "";
}
return achou[3] + "/" + achou[2] + "/" + achou[1];
}
A solução é quase boba: pegar ano, mês e dia direto do texto com uma expressão regular e remontar no formato brasileiro. Nenhuma conversão de fuso, nenhuma surpresa. O que está escrito no XML é o que sai no papel, que é exatamente o que o fiscal espera encontrar. ✨
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Existe uma biblioteca que gera a DANFE pronta?
djf-nfe lê o XML e te entrega os campos, mas o desenho da folha é seu. É por isso que este artigo monta o HTML na mão: o layout do DANFE é uma tabela de blocos fixos, e escrever em HTML dá menos trabalho que posicionar caixas num PDF.Por que a placa do veículo aparece vazia no meu DANFE?
<transporta>. A placa, a UF da placa e o código ANTT moram em <veicTransp>, que é outro grupo dentro de <transp>. Procurar a placa no transportador devolve vazio sem erro nenhum.O que é o "SEM VALOR FISCAL" escrito atravessado na folha?
tpAmb vale 2, que é o ambiente de homologação. Sem ela o seu DANFE de teste vira um documento falso convincente. E ela só sai no PDF se o page.pdf() receber printBackground: true.Por que o primeiro produto some da tabela de itens?
djf-nfe numera os itens de 1 até nrItens(), e não de zero. Um for começando em 0 chama nfe.item(0), que devolve undefined, e o laço termina antes do último produto de verdade.Por que não usar new Date() para formatar a data da nota?
2026-03-10T14:30:00-03:00, com fuso próprio. O new Date() reinterpreta esse instante pelo fuso da máquina, e uma nota emitida perto da meia-noite aparece no dia errado. Uma expressão regular pegando ano, mês e dia do texto resolve sem risco.Preciso de uma fonte de código de barras instalada?
Leia também
API Mágica: notificações Web Push no navegador com PHP
Web Push com a API Mágica em PHP puro: o navegador se inscreve, o seu site envia a notificação e a API conta o clique. Sem Composer e sem biblioteca.
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.