O requisito era banal: um botão Exportar para Word que produzisse um arquivo que o Word realmente abrisse, com a formatação intacta, a partir de um app sem servidor. A resposta óbvia é uma biblioteca. A resposta óbvia custa entre 200 KB e 500 KB minificados, em um site cujo payload inteiro de JavaScript é menor do que isso, por um recurso que a maioria das pessoas nunca clica.
Então a pergunta virou quanto do formato de fato precisa ser implementado. A resposta acabou sendo menos do que o esperado em uma direção e consideravelmente mais em outra.
O ZIP não precisa ser comprimido
Um .docx é um arquivo ZIP com um conjunto específico de arquivos dentro. O ZIP aceita
vários métodos de compressão e todo compactador real usa deflate — mas o método
0, store, também está na especificação, significa "os bytes estão
aqui na íntegra" e é aceito por toda implementação que lê ZIP.
Isso elimina a única dependência genuinamente difícil. As partes de um documento pequeno são alguns
kilobytes de XML; pular o deflate custa um pouco de tamanho de arquivo e evita embarcar um
compressor ou depender do CompressionStream, o que teria deixado o recurso
indisponível no Safari mais antigo.
O que sobra é o layout de bytes. Um arquivo ZIP são três tipos de registro escritos em ordem fixa, cada um introduzido por um número mágico de quatro bytes:
O dimensionamento do buffer sai direto dos tamanhos dos registros, então o arquivo inteiro é uma
alocação e um DataView — sem concatenação de arrays, sem crescimento:
// 30 bytes local header + name + data per entry; 46 + name per central entry; 22 EOCD.
const localSize = records.reduce((n, r) => n + 30 + r.name.length + r.data.length, 0);
const centralSize = records.reduce((n, r) => n + 46 + r.name.length, 0);
const out = new Uint8Array(localSize + centralSize + 22);
const view = new DataView(out.buffer);
Toda escrita é view.setUint32(offset, value, true) — esse terceiro argumento é
little-endian, e ele está em cada uma das chamadas.
CRC-32, em doze linhas
O único algoritmo que precisa ser implementado é o checksum. É o CRC-32 padrão orientado a tabela
com o polinômio 0xEDB88320, construído uma vez no carregamento do módulo:
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(data: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
O >>> 0 no final não é enfeite. Os operadores bit a bit do JavaScript produzem
inteiros de 32 bits com sinal, então sem o deslocamento sem sinal o resultado pode ser negativo e o
setUint32 escreve os bytes errados. É o tipo de coisa que produz um arquivo correto
para a maioria das entradas e corrompido para algumas.
Timestamps DOS, que são mais estranhos do que precisariam
O ZIP guarda datas de modificação no formato empacotado do MS-DOS de 1980, e isso tem duas consequências que vale conhecer antes de gastar uma tarde com um erro de um: o ano é guardado como deslocamento a partir de 1980, e o campo de segundos guarda unidades de dois segundos, então segundos ímpares não sobrevivem à ida e volta.
function dosDateTime(d: Date): { date: number; time: number } {
return {
date: (((d.getFullYear() - 1980) & 0x7f) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
};
}
Nada no Word lê esse campo de um jeito que alguém perceba, mas escrever algo estruturalmente inválido ali é uma forma gratuita de fazer o arquivo parecer suspeito para um extrator rigoroso.
Sete partes, e cada relacionamento declarado por extenso
Dentro do arquivo, um .docx segue as Open Packaging Conventions. A regra que pega as
pessoas é que nada é descoberto por convenção — toda parte precisa ser declarada por tipo de
conteúdo, e toda referência entre partes precisa passar por um arquivo de relacionamentos
explícito. O Word não adivinha.
rId, e o arquivo de relacionamentos é gerado depois, a partir da lista acumulada.Cada parte é uma template string. Não há nenhum serializador de DOM XML envolvido em lugar algum deste pipeline — as partes são pequenas, totalmente conhecidas e mais fáceis de ler como XML literal do que como chamadas de builder.
Leia a formatação do estilo calculado, não da marcação
O exportador percorre o DOM vivo do editor em modo leitura e pergunta ao
getComputedStyle por cada propriedade, em vez de analisar atributos
style. Essa decisão se paga três vezes.
Ela resolve os padrões da folha de estilo — um h1 estilizado com
font-size: 2em no CSS do editor é reportado como 28px, e o exportador
nunca precisa saber que a regra existe. Ela funde os dois caminhos pelos quais um tamanho pode
chegar (uma regra de folha de estilo, ou um span inline escrito pela ferramenta de tamanho de
fonte) em um único trecho de código. E ela torna o zoom gratuito: o zoom é implementado como
transform: scale(), e uma transform não afeta as métricas calculadas da fonte, então
um documento exportado com zoom de 150% é idêntico byte a byte a um exportado com 100%.
Há um lugar em que o estilo calculado é ativamente enganoso, e vale conhecer porque o bug que ele
produz é sutil. Decorações de texto não são herdadas do jeito que o peso da fonte é. Um
<b> aninhado dentro de um <u> reporta
text-decoration: none para si mesmo, mesmo estando visivelmente sublinhado na tela,
porque o sublinhado é pintado pelo ancestral. Então as decorações precisam ser combinadas com OU ao
longo da descida, em vez de lidas do zero em cada nó:
p.underline = inherited.underline || tag === 'U' || deco.includes('underline');
p.strike = inherited.strike || tag === 'S' || tag === 'STRIKE' || deco.includes('line-through');
Unidades, e de onde vêm os números mágicos
O OOXML mede em várias unidades ao mesmo tempo, nenhuma delas pixels. Cada conversão é uma única constante, e cada constante tem uma derivação que vale anotar uma vez:
A ordem dos filhos no OOXML não é sugestão
Esta é a parte que mais custa tempo se você nunca a encontrou. O schema de w:pPr e
w:rPr é uma sequência, não um conjunto. Os filhos têm ordem definida, e o
Word valida isso. Coloque w:bidi depois de w:ind em vez de antes e o
arquivo não renderiza um pouco errado — ele não abre, com uma caixa de diálogo dizendo que o
conteúdo é ilegível e oferecendo recuperá-lo.
Toda propriedade de caractere tem um gêmeo para escritas complexas
O Word mantém dois conjuntos paralelos de propriedades de caractere: um para texto latino e um para
escritas complexas — árabe, hebraico, thaana, siríaco e as escritas índicas. Um run marcado com
<w:rtl/> é lido como texto de escrita complexa, e o Word então procura a fonte,
o tamanho e o peso dele no conjunto de escrita complexa. Se você só escreveu o conjunto
latino, esse run é renderizado na fonte padrão de escrita complexa do Word, no tamanho padrão,
ignorando tudo o que você especificou.
Então negrito é <w:b/><w:bCs/>. Itálico é
<w:i/><w:iCs/>. Tamanho é
<w:sz/><w:szCs/>. A fonte é um elemento com três atributos.
function rPrXml(p: RunProps, hyperlink = false, rtl = false): string {
const parts: string[] = [];
if (hyperlink) parts.push('<w:rStyle w:val="Hyperlink"/>');
if (p.font) {
const f = escapeXml(p.font);
parts.push(`<w:rFonts w:ascii="${f}" w:hAnsi="${f}" w:cs="${f}"/>`);
}
if (p.bold) parts.push('<w:b/><w:bCs/>');
if (p.italic) parts.push('<w:i/><w:iCs/>');
if (p.strike) parts.push('<w:strike/>');
if (p.color) parts.push(`<w:color w:val="${p.color}"/>`);
if (p.sizeHalfPts) parts.push(`<w:sz w:val="${p.sizeHalfPts}"/><w:szCs w:val="${p.sizeHalfPts}"/>`);
if (p.underline) parts.push('<w:u w:val="single"/>');
if (p.shading) parts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${p.shading}"/>`);
if (rtl) parts.push('<w:rtl/>');
return parts.length ? `<w:rPr>${parts.join('')}</w:rPr>` : '';
}
A direção precisa ser declarada, nunca inferida
O Word não roda a regra Unicode do primeiro caractere forte no seu texto. Se você quer um parágrafo disposto da direita para a esquerda, você diz. São três elementos separados, em três escopos diferentes, e eles fazem trabalhos diferentes:
| Elemento | Escopo | O que faz |
|---|---|---|
<w:bidi/> |
w:pPr |
Inverte a direção base do parágrafo: recuo, alinhamento, medição de tabulações e fluxo do texto. |
<w:rtl/> |
w:rPr |
Marca um run como escrita complexa, definindo sua ordem de leitura e trocando-o para o conjunto de propriedades Cs. |
<w:bidiVisual/> |
w:tblPr |
Inverte a ordem das colunas para que a primeira fique à direita. |
Emita w:bidi sem w:rtl nos runs e o texto cai no lugar certo com os
caracteres lendo ao contrário. Emita w:rtl sem w:bidi e os caracteres leem
corretamente dentro de um parágrafo que continua recuado e alinhado pela esquerda. Você precisa dos
dois, e eles estão especificados em seções diferentes do ECMA-376 — §17.3.1.6 e §17.3.2.30.
A parte útil é que o exportador não reimplementa a detecção. Ele importa o mesmo
detectDirection que o editor na tela usa e o aplica por run:
// First-strong, matching how the editor decides paragraph direction, so a
// mostly-Latin run with an Arabic word in it is not flipped wholesale.
const rPr = rPrXml(props, hyperlink, detectDirection(cleaned) === 'rtl');
Uma implementação, dois consumidores. O arquivo não pode discordar da tela, porque não há do que
discordar. O lado da tela está em
por que dir="auto" quebra um
editor multilíngue.
w:jc é físico, o que muda quando você o emite
O alinhamento em CSS tem valores lógicos — start e end resolvem contra a
direção do elemento. O w:jc do Word não: left significa a esquerda da
página, independentemente de para que lado o parágrafo corre.
Então o exportador não pode traduzir o alinhamento CSS direto. Omitir o w:jc por
completo deixa o parágrafo na sua borda inicial, que o w:bidi já moveu para a direita —
que é exatamente o que um parágrafo RTL sem alinhamento deve fazer. Um
w:jc w:val="left" explícito o arrastaria de volta para a esquerda e desfaria a
inversão.
// w:jc left/right are physical in Word. Omitting it leaves the paragraph on
// its start edge, which w:bidi has already moved to the right — so an RTL
// paragraph only needs a w:jc when the user picked an alignment explicitly.
if (align && (align !== 'left' || rtl)) pr.push(`<w:jc w:val="${align}"/>`);
A mesma divisão entre físico e lógico vale para o recuo: w:ind w:left vira
w:ind w:right em um parágrafo RTL, e a borda de uma citação passa de
w:left para w:right, porque nos dois casos o que a pessoa quis dizer foi
"a borda de onde o texto começa".
Três formas de tornar o arquivo ilegível
Espaços em branco que colapsam através de fronteiras de elemento
O HTML colapsa sequências de espaços, e as colapsa através de fronteiras de elemento — o espaço
depois de </b> e o espaço antes da palavra seguinte são um espaço só na tela. O
XML não colapsa nada, e uma quebra de linha literal dentro de <w:t> é um
caractere que o Word vai renderizar.
Então a descida carrega uma pequena flag mutável — "estamos no início de um parágrafo?" — por cada run, colapsa cada nó de texto contra ela, e corta um espaço inicial no começo do parágrafo. A flag é substituída, não mutada, quando um novo parágrafo começa, para que elementos inline aninhados compartilhem uma visão só do estado de espaçamento sem vazá-la entre blocos.
Caracteres que o XML 1.0 não permite
O XML 1.0 proíbe a maioria dos caracteres de controle por completo — não existe sequência de escape
para eles, e um 0x0B cru em um nó de texto torna a parte ilegível, não importa como
esteja codificada. Conteúdo colado de PDFs e terminais os contém com mais frequência do que se
imagina.
O sanitizador descarta esses, e descarta mais um caractere de propósito:
if (code === 0x200b) continue; // zero-width space from applyFontSize
O U+200B não é ilegal em XML — ele é descartado porque o editor o injeta. Definir um tamanho de fonte sem nada selecionado insere um espaço de largura zero para manter o span vazio vivo, o que está explicado aqui. Ele é invisível na tela, invisível no HTML salvo, e de outro modo acabaria no arquivo exportado sem servir para nada.
Marcação de destaque vazando do Localizar e Substituir
O Localizar e Substituir envolve as ocorrências em elementos <mark> para
destacá-las. Isso é interface transitória, não conteúdo — mas são nós reais no DOM do editor, e uma
descida que lê a cor de fundo calculada vai fielmente exportar um destaque amarelo atrás de cada
resultado de busca.
Então o <mark> é excluído explicitamente da extração de sombreamento. Vale notar
que o mesmo vazamento precisa ser bloqueado de forma independente em três lugares: a descida do
DOCX, o sandbox do PDF e o caminho de salvamento automático — que remove as marcas antes de
despachar seu evento de mudança, para que a marcação de destaque nunca seja persistida na aba.
Um <w:tc> precisa conter pelo menos um <w:p>. Uma célula de
tabela vazia não é um elemento vazio — é uma célula contendo um parágrafo vazio.
Um <w:p/> vazio precisa ser acrescentado depois de toda tabela, ou duas tabelas
adjacentes se fundem em uma quando o Word abre o arquivo.
Marcadores vivem em uma área de uso privado
Os glifos de lista de fábrica do Word não são caracteres de marcador Unicode. São glifos das fontes Symbol e Wingdings, endereçados em code points da área de uso privado — o que significa que o caractere de que você precisa não pode ser digitado, colado ou copiado com segurança de um documento de referência sem que uma substituição de fonte o estrague no caminho.
// Word's stock bullet glyphs live in the Symbol and Wingdings private-use
// range, so they are built from char codes rather than pasted literals.
const BULLETS = [
{ text: String.fromCharCode(0xf0b7), font: 'Symbol' }, // filled round
{ text: 'o', font: 'Courier New' }, // hollow round
{ text: String.fromCharCode(0xf0a7), font: 'Wingdings' }, // filled square
];
Construí-los a partir de códigos de caractere não é preferência de estilo. Um U+F0B7
colado em um arquivo-fonte fica à mercê de todo editor, linter e passo de build por onde passa, e a
falha é silenciosa — você recebe um marcador diferente, ou uma caixinha de substituição, só no
arquivo exportado.
Valeu a pena?
Para este app, sim, mas o raciocínio é específico, não geral.
O pipeline inteiro de DOCX são três arquivos: 114 linhas de gerador de ZIP, 472 linhas de emissor de OOXML e 199 linhas de montagem do pacote. São menos de 800 linhas contra 200–500 KB de dependência, e nada disso é código que muda — o formato ZIP está congelado e o ECMA-376 está estável há bem mais de uma década. Não é o tipo de dependência que cobra aluguel em correções de segurança e atualizações que quebram.
Também importa que este é um problema de somente escrita. O exportador emite documentos;
ele nunca os lê. Analisar arquivos .docx arbitrários escritos pelo Word, Google Docs,
LibreOffice e vinte anos de outras ferramentas é um problema de outra escala, e ali a resposta é,
sem ambiguidade, "use a biblioteca".
O que ele não faz vale dizer com todas as letras. Não há suporte a imagens — nem
w:drawing, nem partes de mídia, nem relacionamentos com conteúdo binário. Sem notas de
rodapé, sem comentários, sem controle de alterações, sem cabeçalhos e rodapés, sem quebras de
seção, sem partes de tema ou tabela de fontes. As tabelas são emitidas com largura fixa de 100% e
sem dimensionamento de colunas. Para o botão de exportar de um bloco de notas esse é o escopo
certo; para qualquer coisa que se aproxime de um editor de documentos, não é.
-
Use o método de compressão
0. É ZIP válido, e remove a única dependência que o problema genuinamente tem. -
Calcule os tamanhos antes e escreva em um único
Uint8Array. Todo campo é little-endian. -
Leia a formatação do
getComputedStyle, não da marcação — mas combine as decorações de texto com OU ao descer a árvore, porque elas não são herdadas. -
Respeite a ordem dos filhos de
w:pPrew:rPr. O modo de falha é um arquivo que não abre, com uma mensagem de erro que não nomeia nada. -
Escreva o gêmeo
Csde toda propriedade de caractere, ou os runs de escrita complexa ignoram sua formatação por inteiro. -
Declare a direção explicitamente com
w:bidiew:rtl, e lembre quew:jcew:indsão físicos enquanto o CSS é lógico. - Remova caracteres de controle, U+200B e qualquer marcação transitória de interface antes que chegue ao arquivo.