mgcode-docx-editor
v0.2.4
Published
WYSIWYG .docx template editor (React) with {{variable}} placeholder support, built for docxtemplater/LibreOffice pipelines.
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. 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).
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. 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).
