mgcode-docx-editor
v0.2.9
Published
WYSIWYG .docx template editor (React) with {{variable}} placeholder support, built for docxtemplater/LibreOffice pipelines.
Downloads
108
Readme
mgcode-docx-editor
Editor WYSIWYG de templates .docx feito do zero (contentEditable + document.execCommand),
com a "casca" (toolbar, régua, zoom) em Material UI. Autora um documento com
cabeçalho/rodapé fixos e corpo com placeholders {{variavel}}, e exporta um
.docx válido para o fluxo LibreOffice → PDF.
Instalação
npm install mgcode-docx-editorPeer dependencies (devem estar instaladas no projeto consumidor): react, react-dom,
@mui/material, @mui/icons-material, @emotion/react, @emotion/styled.
API
import { useRef } from 'react';
import { DocxEditor, DocxEditorHandle } from 'mgcode-docx-editor';
const editorRef = useRef<DocxEditorHandle>(null);
<DocxEditor
ref={editorRef}
documentBuffer={documentBuffer ?? undefined} // importar .docx existente
document={documentBuffer ? undefined : documentModel} // ou criar a partir de modelo
mode="editing" // 'editing' | 'viewing'
showToolbar
showRuler
showZoomControl
/>;
// Salvar:
const buffer = await editorRef.current?.save(); // Promise<ArrayBuffer> (.docx)Props (DocxEditorProps)
| Prop | Tipo | Descrição |
| --- | --- | --- |
| documentBuffer | ArrayBuffer? | Importa um .docx existente. Tem precedência sobre document. |
| document | DocumentModel? | Cria/edita a partir de um modelo estruturado. |
| mode | 'editing' \| 'viewing' | Editável ou somente leitura. |
| showToolbar | boolean? | Exibe a barra de ferramentas. |
| showRuler | boolean? | Exibe a régua funcional. |
| showZoomControl | boolean? | Exibe o controle de zoom. |
Handle imperativo (DocxEditorHandle, via ref)
save(): Promise<ArrayBuffer>— serializa o conteúdo atual para.docx.getModel(): DocumentModel— retorna o modelo atual (header/body/footer/variables).
DocumentModel
interface DocumentModel {
header?: string; // HTML do cabeçalho fixo (ex.: logo)
body: string; // HTML do corpo (contém {{variaveis}})
footer?: string; // HTML do rodapé fixo
variables?: string[]; // nomes para o menu "Inserir variável"
}Arquitetura
- Editor: três regiões
contentEditable(header/body/footer) +execCommand. - Página: A4 (794×1123 px a 96dpi na tela; 11906×16838 twips no export,
via
PAGE_SIZE_A4_TWIPSemdocx/generate.ts). - Exportação (
save()):@turbodocx/html-to-docx(MIT) converte o HTML das regiões em.docx, com cabeçalho/rodapé como partes do Word e margens da régua comow:pgMar. A lib reconhecefont-weight/text-decorationdiretos numstyle=""(negrito/sublinhado), mas não tem tratamento parafont-style— só reconhece itálico via tag semântica (<em>/<i>). Como o editor sempre aplica formatação comstyleWithCSSligado (editorCommands.ts), itálico virafont-style: italicnum<span>, que a lib descartaria silenciosamente; contornado envolvendo esse conteúdo em<em>antes da conversão (wrapItalicElementsemdocx/generate.ts). Verdocx/generate.ts. - Importação (
documentBuffer):mammoth(BSD-2) converte o corpo do.docxem HTML. Cabeçalho e rodapé (incluindo imagens embutidas) são recuperados à parte: o.docxé reaberto comJSZip, a parteword/header*.xml/footer*.xmlé reempacotada como umword/document.xmlsintético apontando para as relações da própria parte, e reenviada ao mammoth — assim as imagens saem como<img src="data:...">, igual às do corpo. Como o mammoth não reconhecew:fldSimple(e descarta o elemento inteiro, cache incluso), os campos PAGE/NUMPAGES são desfeitos de volta para os tokens[[PAGINA]]/[[TOTAL_PAGINAS]]antes desse envio (restorePageFieldTokensemdocx/pageFields.ts, inverso deinjectPageFields). O mammoth por padrão só mapeia formatação semântica (estilos nomeados) e descarta alinhamento de parágrafo, cor de texto, fonte e tamanho de fonte diretos (aplicados via toolbar, sem estilo nomeado por trás); sublinhado é lido mas não tem tag HTML padrão associada. Contornado comtransformDocument(marca parágrafos alinhados com umstyleIdsintético) e reescrita do XML antes do envio (injeta um únicow:rStylesintético por run combinando cor/fonte/tamanho diretos, já que um run só tem umstyleId), resolvidos viastyleMapdinâmico + a linha estáticau => u. O tamanho de exibição das imagens (wp:extent) também é descartado pelo mammoth — recuperado anexando um marcador com o tamanho em px aodescrdowp:docPr(único dado por imagem que o mammoth carrega até o HTML, comoalt) e convertendo-o emstyle="width/height"na<img>depois da conversão. Bordas de tabela (w:tblBorders/w:tcBorders) não chegam a entrar na árvore do documento do mammoth (nenhum dado pra recuperar, diferente dos casos acima) — toda<table>/<td>/<th>importada recebe o mesmo visual padrão queinsertTableIntoRegionusa ao criar tabela pela toolbar (applyDefaultTableStyles), pra nunca importar uma tabela sem nenhuma linha visível. Verdocx/importDocx.ts. - Régua: arrasta margens de página (→ margens da seção do
.docx) e recuos de parágrafo. Conversões px↔twips emdocx/units.ts.
Polyfill de Buffer (imagens no browser)
O bundle browser da @turbodocx/html-to-docx usa o global Buffer do Node para
decodificar imagens base64; CRA/webpack 5 não polyfilla globais do Node, o que
causava "Buffer is not defined" ao salvar documentos com imagens. Correção:
dependência buffer (feross/buffer, MIT) e ensureBufferGlobal() chamado no
início de modelToDocx() em docx/generate.ts — atribui globalThis.Buffer ao
polyfill se indefinido. É feito em tempo de chamada (não de import), então
funciona independentemente da ordem de carregamento e não sobrescreve um
Buffer já existente (Node/Jest).
Cor e tamanho de fonte explícitos (evita divergência entre o editor e o .docx exportado)
O app anfitrião pode ter um tema (claro/escuro) cujo CSS global vaza para
dentro das regiões editáveis (ex.: p, div { color: white } via
MuiCssBaseline), fazendo texto novo aparecer branco/invisível no editor —
mesmo que a exportação saia preta por padrão, já que ela lê o HTML das regiões,
não o computed style renderizado. ensureExplicitTextStyle() em
editorCommands.ts elimina essa divergência: roda na montagem/importação
inicial e após qualquer edição (onInput), envolvendo em um único
<span style="color:...; font-size:..."> todo texto que ainda não tem cor e/ou
tamanho de fonte explícitos herdados de um ancestral dentro da região (só a
propriedade que falta é adicionada — se só uma já é explícita, a outra é
completada num novo span aninhado em vez de duplicar a que já existe). Exceção:
texto (ou formatação puramente inline, como <b>/<i>, aplicada antes de
existir parágrafo) sem nenhum bloco/parágrafo ancestral dentro da região —
caso do primeiro caractere digitado (ou formatado) num documento em branco,
antes de o browser criar o primeiro <p> — é promovido a
<p style="color:...; font-size:..."> em vez de receber um <span>, pois um
<span> (ou texto cru) direto na região, fora de qualquer parágrafo, não é o
que o exportador de .docx espera. Texto
com formatação customizada (cor via foreColor, tamanho via os botões -/+
da toolbar, ou vinda de importação) não é tocado — o fallback de cor da região
(color: #000000 em DocxEditor.tsx) não usa !important justamente para
nunca vencer uma cor customizada por especificidade. O tamanho de fonte usado
como default é sempre o valor atualmente selecionado na toolbar (rastreado em
currentFontSizeRef em DocxEditor.tsx, atualizado a cada clique em -/+),
não um valor fixo. Como o header/footer espelhado nas páginas 2+ é HTML solto
(dangerouslySetInnerHTML, nunca uma região contentEditable de verdade),
passa pela variante ensureExplicitTextStyleInHtml() antes de virar o espelho,
para ter a mesma garantia. Efeito colateral esperado: getModel() agora sempre
inclui um <span style="color: rgb(0, 0, 0); font-size: ...pt"> ao redor de
texto "simples" (sem formatação customizada) que passou por essa normalização.
O número do tamanho de fonte exibido entre os botões -/+ na toolbar
(Toolbar.tsx) também tem cor fixa (#000000, não theme.palette.text.*)
pelo mesmo motivo: um tema escuro injetado pelo app anfitrião tornaria esse
número branco/invisível sobre o fundo claro da toolbar.
A normalização roda dentro do handler de input, mutando o DOM sincronamente
(replaceWith/appendChild do nó de texto que está sendo editado). Mover um
nó de texto que ainda está com o cursor nele reseta a Selection do browser
para o início da região — sem correção, isso fazia o cursor "pular" para o
início a cada caractere digitado que disparasse a normalização (visível ao
digitar no início de um documento em branco: as letras saíam fora de ordem).
ensureExplicitTextStyle() guarda a posição do cursor (nó + offset) antes de
mover o texto e a restaura no mesmo nó logo depois, via
Selection.setBaseAndExtent(). Validado tanto em jsdom (teste dedicado) quanto
em browser real via Playwright.
Limitação conhecida (não coberta por teste automatizado, jsdom não simula
digitação real — ver seção de Testes): essa restauração de cursor não foi
validada manualmente contra undo/redo do navegador nem contra composição de
IME fora de uma sessão de composição ativa (o handler ignora eventos com
isComposing, mas o comportamento de undo/redo com a árvore DOM mutada não
foi verificado em um browser real).
Paginação visual (estilo Word)
O editor renderiza N caixas "papel" A4 posicionadas absolutamente
(data-page-index em cada uma, para testes/automação), mas o corpo continua
sendo um único contentEditable contínuo atravessando todas as páginas —
o DOM nunca é dividido, então cursor, undo e execCommand seguem funcionando.
┌─ página 0 ──────────────┐
│ header REAL (editável) │ ← 48px da borda (distância do Word)
│ corpo contínuo… │
│ footer REAL (editável) │
├─ gap 24px ──────────────┤
┌─ página 1+ ─────────────┐
│ espelho header (RO) │ ← aria-hidden, dangerouslySetInnerHTML
│ …corpo continua │ sincronizado a cada input
│ espelho footer (RO) │
└─────────────────────────┘- Quebra por bloco:
docx/pagination.ts(módulo puro, sem DOM no cálculo).computePageLayout(blocks, {usableHeightPx, interPageGapPx})decide, para cada bloco top-level do corpo, se ele cruzaria a zona do rodapé; se sim (e couber inteiro numa página), um espaçador não-editável é inserido antes dele (createSpacerElement(): div comdata-page-spacer,contenteditable=false), empurrando-o para a próxima página. - Contrato de coordenadas:
BlockBox.topé em coordenadas sem espaçadores; a medição do DOM emDocxEditor.tsxsubtrai a altura acumulada dos espaçadores acima de cada bloco antes de chamarcomputePageLayout. - Recomputação: a cada input, mudança de margens/régua, import de
.docxeloadde imagem (listener em fase de captura), com throttle viarequestAnimationFrame(schedulePagination). O ciclo é auto-corrigível: remove todos os espaçadores e reinsere do zero. - Header/footer editáveis vivem apenas na página 1, nas áreas de margem; páginas 2+ mostram espelhos somente leitura sincronizados a cada edição.
- Sem rastro no modelo:
buildModel()/save()removem os espaçadores viastripPageSpacers()— a paginação é puramente visual e o round-trip do HTML do corpo é exato (há teste para isso).
Indicador de região em edição (estilo Word)
DocxEditor.tsx mantém o estado focusedRegion: 'header' | 'body' | 'footer' | null
(atualizado em onRegionFocus/onRegionBlur):
- Ao focar o cabeçalho ou rodapé, a região ganha contorno tracejado
(
outline: 1px dashed, cor primária) + etiquetaChipMUI ("Cabeçalho"/"Rodapé"), e o corpo esmaece (opacity: 0.45, via a flag derivadaeditingChrome). - Ao editar o corpo, header/footer reais e os espelhos das páginas 2+ esmaecem
levemente (
opacity: 0.55).
É indicação puramente visual — não altera modelo nem exportação.
Variáveis {{...}}
São inseridas como texto literal contíguo (via Range API, não execCommand),
garantindo que o LibreOffice/docxtemplater consiga substituí-las — o token nunca é
quebrado entre runs do Word. Há teste dedicado para isso em
docx/__tests__/generate.test.ts.
Campos de página do Word ([[PAGINA]] / [[TOTAL_PAGINAS]])
Módulo docx/pageFields.ts. O usuário insere tokens literais [[PAGINA]] e
[[TOTAL_PAGINAS]] que, na exportação, viram campos nativos do Word
(PAGE/NUMPAGES) que o Word/LibreOffice atualizam sozinhos.
- Sintaxe
[[...]]é deliberada: diferente de{{...}}para o docxtemplater nunca tentar substituí-los no pipeline. - Inserção: menu "Variável" da toolbar (agora sempre visível, mesmo sem
variables), itens "Nº da página" e "Total de páginas" → proponInsertPageFielddoToolbar.tsx→handleInsertPageFieldemDocxEditor.tsx, que insere via Range API (contíguo, como as variáveis). Sem região focada, o fallback é o rodapé (onde campos de página fazem sentido). - Exportação (
modelToDocxemdocx/generate.ts): sehasPageFieldTokens()detecta tokens no header/footer do modelo, o.docxgerado pela lib é pós-processado com JSZip: em cadaword/header*.xmleword/footer*.xml,injectPageFields()substitui cada token por<w:fldSimple w:instr=" PAGE ">/" NUMPAGES ". Sem tokens, o buffer sai intacto (nenhum reprocessamento do zip). - Processamento run a run (invariante crítico):
injectPageFields()itera o XML comRUN_PATTERN, que casa um<r>…</r>inteiro por vez — o lazy até o primeiro</r>nunca atravessa runs vizinhos, porque runs não aninham em OOXML. Cada run com token é reconstruído porrebuildRun()+splitTextByTokens()como uma sequência ordenada de runs de texto efldSimple, copiando orProriginal para dentro de cada pedaço — a formatação (negrito, fonte etc.) é preservada. Por que run a run: a implementação antiga usava um único regex sobre o XML inteiro cujo grupo lazy dorPrpodia "esticar" por cima de runs vizinhos quando havia um run anterior com formatação não vazia — o resultado era XML embaralhado (fldSimpleaninhado, texto duplicado) e o Word recusava o arquivo. Há teste de regressão dedicado emdocx/__tests__/pageFields.test.ts. - Dois formatos de OOXML:
RUN_PATTERN/rebuildRun()aceitam XML prefixado (<w:r>, como no document.xml) e em namespace default sem prefixo (<r>, formato dos header/footer gerados pela@turbodocx/html-to-docx). No caso sem prefixo, ofldSimpledeclaraxmlns:wno próprio elemento, exigido pelo atributow:instr. - Valor em cache: o campo carrega o texto "1", exibido até o Word/LibreOffice atualizar os campos.
- Preview no editor: nos espelhos das páginas 2+,
withPagePreview()emDocxEditor.tsxtroca os tokens pelos números reais ("Página 2 de 3"); na página 1 (regiões editáveis) os tokens ficam visíveis como texto.
Redimensionamento de imagens
Implementado em DocxEditor.tsx (estado selectedImg + frame), sem tocar no
execCommand:
- Seleção: delegação de clique na coluna de páginas (
handleColumnClick) — clicar num<img>dentro de qualquer regiãocontentEditableseleciona a imagem; clicar fora deseleciona. Emmode="viewing"não há seleção. - Moldura: uma overlay absoluta sobre a coluna de páginas
(
data-testid="image-resize-frame") com 4 alças de canto (data-testid="image-resize-handle").updateFrame()posiciona a moldura a partir degetBoundingClientRect()da imagem, dividindo pelo zoom (a coluna é escalada viatransform: scale, mas a overlay vive em coordenadas sem zoom). A moldura tempointer-events: none; só as alças são interativas. - Drag (
startResize): durante o arraste só a largura é gravada (style.widthem px,height: automantém a proporção); o delta do mouse é compensado pelo zoom e a largura é limitada entre 20px e a largura da região. Nomouseup, a altura é fixada em px (style.height) ehandleContentChanged()dispara repaginação + sincronização dos espelhos. - Caminho até o
.docx(ida e volta): a exportação (@turbodocx/html-to-docx) lê ostyle="width/height"em px da imagem e converte px→EMU (1px = 9525 EMU) nowp:extent— o tamanho redimensionado vai para o arquivo. Na reimportação, owp:extenté convertido de volta parastyle="width/height"em px (emuToPxemdocx/units.ts), então o resize sobrevive ao ciclo salvar→reimportar. Testes emdocx/__tests__/generate.test.ts(ida) edocx/__tests__/importDocx.test.ts(volta). - Deseleção automática: ao clicar fora, ao reimportar um
.docx(docVersionmuda) ou se a imagem sair do DOM (img.isConnectedé checado emupdateFrame()); a moldura também é reposicionada quando layout, margens ou espelhos mudam.
Licenças das dependências (todas permissivas / uso comercial livre)
@turbodocx/html-to-docx— MITmammoth— BSD-2-Clause@mui/material/@mui/icons-material— MITbuffer(feross/buffer, polyfill do globalBuffer) — MITjszip(pós-processamento dos campos de página e recuperação de cabeçalho/rodapé na importação) — MIT
Limitações conhecidas (v1)
documentedocumentBuffersão valores iniciais (componente não controlado, como editores em geral). Trocar essas props depois de montado não re-sincroniza o conteúdo já editado, exceto por um novodocumentBuffer(que dispara reimportação).- Import de
.docxrecupera corpo, cabeçalho e rodapé (com imagens). Isso cobre tanto templates criados por este editor quanto, em base melhor esforço, arquivos de terceiros com um único cabeçalho/rodapé "default" por seção — não há suporte a variações por primeira página/página par nem a múltiplas seções com cabeçalhos diferentes (nesses casos, o cabeçalho/rodapé pode não ser recuperado, mas o corpo continua funcionando normalmente). - Alinhamento de parágrafo, cor de texto, fonte, tamanho de fonte e sublinhado
diretos (sem estilo nomeado do Word por trás) são recuperados na
importação. Casos fora de escopo, também melhor esforço: um parágrafo/run
com um estilo nomeado do Word (Heading, Hyperlink, Strong etc.) que
também tenha alinhamento/cor/fonte sobrescritos diretamente não recupera
esse override; cor via tema (
w:themeColorsemw:valhex) ouw:val="auto"também não é recuperada. - O tamanho de exibição das imagens (
wp:extentdewp:inline/wp:anchor) é recuperado na importação. Fora de escopo (melhor esforço): imagens VML legadas (w:pict) e drawings semwp:docProu semwp:extentimportam no tamanho natural do arquivo de mídia. - Bordas/estilo visual de tabela (cor, espessura, tabelas sem borda de propósito) não são recuperados na importação — o mammoth não lê essa formatação de forma alguma. Toda tabela importada ganha a borda padrão do editor (linha simples preta, 1px) em vez da borda original.
document.execCommandé depreciado (porém suportado em todos os browsers atuais); é a escolha deliberada para um editor "do zero" sem framework de edição.- Colar conteúdo do Word ainda não é sanitizado (escopo futuro).
- Paginação por bloco apenas: um parágrafo nunca é dividido no meio entre páginas — o bloco inteiro é empurrado. Blocos mais altos que a área útil de uma página não são divididos e transbordam sobre a página seguinte.
- Os espelhos de cabeçalho/rodapé nas páginas 2+ são somente leitura; a edição acontece nas regiões reais da página 1.
Testes
docx/__tests__/units.test.ts— conversões de unidade.docx/__tests__/generate.test.ts—.docxválido, texto/variável preservados, header/footer, contiguidade do{{token}}, imagem base64 →word/media,w:pgSzA4,save()funciona comglobalThis.Bufferremovido (polyfill), tokens[[PAGINA]]/[[TOTAL_PAGINAS]]viramfldSimplePAGE/NUMPAGES no footer (sem tokens vazados), documento sem tokens sai intacto, e caracterização dowp:extent:style="width/height"em px na imagem vira EMU no.docx(1px = 9525 EMU).docx/__tests__/pageFields.test.ts—injectPageFields(): divisão do run preservando texto ao redor erPr, múltiplos tokens no mesmo run, XML com e sem prefixow:(declaração dexmlns:w), XML sem tokens inalterado,hasPageFieldTokens(), e regressão do docx corrompido: "não embaralha o XML quando há run anterior com formatação" (o regex antigo atravessava runs e geravafldSimpleaninhado/texto duplicado).docx/__tests__/pagination.test.ts—computePageLayout(quebras, espaçadores, blocos maiores que a página, contagem de páginas).docx/__tests__/importDocx.test.ts— round-trip de importação.__tests__/DocxEditor.test.tsx—save(), render, inserção de variável, modo leitura,getModel()remove espaçadores com round-trip exato do corpo, etiqueta de foco (Chip "Cabeçalho"/"Rodapé" ao focar a região), inserção dos campos de página pelo menu "Variável", seleção de imagem (clique mostra a moldura com 4 alças; clique fora deseleciona).
A formatação via execCommand, o arraste da régua e a paginação visual
(múltiplas páginas, rodapé ancorado no pé da página, salvar com imagem sem erro
de Buffer) são validados em browser real via script Playwright, pois o
jsdom não implementa execCommand nem layout real. Também validados no browser:
etiquetas e esmaecimento de foco, preview "Página 2 de 2" nos espelhos,
fldSimple no footer.xml do .docx salvo (1 PAGE + 1 NUMPAGES, runs
balanceados, sem aninhamento — todas as partes XML do zip validadas com parser
rigoroso .NET) e o drag de resize de imagem ponta a ponta (arrastar a alça
altera a largura e o wp:extent exportado corresponde ao tamanho arrastado).
