Notepad Neo

Escrevendo um .docx no navegador sem dependências

O formato de arquivo do Word é um ZIP contendo sete arquivos XML. Nada disso precisa de biblioteca — mas tudo precisa estar exatamente certo, porque o modo de falha do Word para uma parte malformada é uma caixa de diálogo, não um aviso.

DH

— cria e mantém o Notepad Neo

· atualizado em · 13 min de leitura

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 layout de bytes de uma entrada ZIP armazenada Um arquivo ZIP é uma sequência de cabeçalhos locais, cada um seguido pelos dados do arquivo, depois um diretório central com um registro de 46 bytes por entrada, e por fim um registro de fim de diretório central de 22 bytes. O cabeçalho local tem 30 bytes mais o nome do arquivo, com sua assinatura no deslocamento 0, flags de propósito geral em 6, método de compressão em 8, hora e data de modificação em 10, CRC-32 em 14, tamanhos comprimido e descomprimido em 18 e 22, e os comprimentos do nome e do campo extra em 26. FILE LAYOUT, START TO END local header 30 + name file data local header 30 + name file data · · · central directory 46 + name each EOCD 22 bytes LOCAL FILE HEADER, FIELD BY FIELD 0x04034b50 0 · 4 bytes version 4 · 2 flags 6 · 2 method 8 · 2 time · date 10 · 4 CRC-32 14 · 4 comp size 18 · 4 raw size 22 · 4 lens 26 · 4 flags = 0x0800 — bit 11, filenames are UTF-8 CRC-32 of the raw bytes. Get this wrong and Word reports the file as corrupt, with no further detail. With method 0, compressed size equals raw size. Both are known before writing, so no data descriptors are needed and the whole output buffer can be sized exactly up front.
Todo campo de múltiplos bytes é little-endian. Isso não é um detalhe que dá para adiar — uma assinatura big-endian simplesmente não é um arquivo ZIP.

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.

As sete partes de um pacote docx mínimo O Content_Types.xml na raiz do arquivo declara o tipo de mídia de cada parte. O arquivo de relacionamentos raiz aponta para word/document.xml, que é o corpo principal. Um segundo arquivo de relacionamentos dentro da pasta word resolve os alvos dos hiperlinks. Estilos e numeração são referenciados a partir do document.xml, e docProps/core.xml carrega os metadados de título. [Content_Types].xml declares every part's media type _rels/.rels names the start part docProps/core.xml title, author, timestamps word/document.xml the body — every paragraph, run and table lives here word/styles.xml Heading1, Hyperlink… word/numbering.xml bullet and number lists word/_rels/document.xml.rels rId → hyperlink target URL Delete a part and forget its relationship entry, and Word treats the whole document as broken.
Os hiperlinks são a única parte disso que é dinâmica. Cada link encontrado durante a descida no DOM recebe um 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:

Convertendo pixels do navegador nas unidades que o OOXML usa Comprimentos convertem de pixels para twips multiplicando por 15, porque há 1440 twips por polegada e o navegador diagrama a 96 pixels por polegada. Tamanhos de fonte convertem para meios-pontos multiplicando por 1,5, porque um pixel é 0,75 ponto e um meio-ponto são dois por ponto. O espaçamento entre linhas é expresso em 240 avos de linha, então um multiplicador de 1,6 vira 384. LENGTHS · twips px × 15 → twips 1440 twips/inch ÷ 96 px/inch = 15 FONT SIZE · half-points px × 1.5 → w:sz px → pt is × 0.75 pt → half-pt is × 2 LINE SPACING · 240ths 1.6 → w:line 384 w:line counts 240ths of one line: 1.6 × 240 PAGE · US Letter, matching the editor paper 816 px × 15 = 12240 twips wide 1056 px × 15 = 15840 twips tall The editor paper is sized in pixels to be Letter at 96dpi, so the page setup falls out for free. 12240 / 1440 = 8.5 in 15840 / 1440 = 11 in
Um twip é um vigésimo de ponto, e um ponto é um setenta e dois avos de polegada. Todo o resto deste diagrama é consequência dessas duas definições e da referência fixa de 96 dpi do navegador.

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.

Ordem dos elementos filhos dentro das propriedades de parágrafo Os filhos de w:pPr precisam aparecer na ordem do schema: pStyle, numPr, pBdr, shd, bidi, ind e então jc. Emitir bidi depois de ind em vez de antes torna o arquivo impossível de abrir, e não apenas mal renderizado. <w:pPr> — REQUIRED ORDER w:pStyle heading w:numPr list level w:pBdr quote rule w:shd shading w:bidi RTL w:ind indent w:jc align swap these two… “Word found unreadable content in document.docx” …and no indication of which element was wrong. Same inside w:tblPr — w:bidiVisual comes before w:tblW, and the same failure applies.
A ordem está definida na parte 1 do ECMA-376. Não existe modo tolerante, e a mensagem de erro nunca nomeia o elemento ofensor — então a técnica prática de depuração é bissectar as propriedades que você emite até o arquivo abrir.

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.

Propriedades de caractere latinas e seus gêmeos de escrita complexa Cada propriedade de run latina tem uma propriedade paralela de escrita complexa que precisa ser escrita junto: w:b com w:bCs, w:i com w:iCs, w:sz com w:szCs, e o atributo w:ascii de w:rFonts com seu atributo w:cs. Sem os gêmeos, um run marcado com w:rtl é renderizado na fonte padrão de escrita complexa do Word, no tamanho padrão. LATIN SET COMPLEX-SCRIPT TWIN MEANING <w:b/> <w:bCs/> bold <w:i/> <w:iCs/> italic <w:sz w:val="28"/> <w:szCs w:val="28"/> 14pt, in half-points w:ascii · w:hAnsi w:cs attributes of w:rFonts WITHOUT THE TWINS, ON A w:rtl RUN Word's default complex-script font, at its default size, not bold. With the twins: the font, size and weight you asked for, in either script.
Esta é a coisa mais surpreendente de escrever OOXML à mão. Toda propriedade é escrita duas vezes, e nada no formato de arquivo dá a entender que deveria ser assim.
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:

ElementoEscopoO 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.

Duas regrinhas específicas do Word que não estão em tutorial nenhum

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 é.

← Todos os artigos de engenharia Experimentar o editor