npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-editor

Peer 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_TWIPS em docx/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 como w:pgMar. A lib reconhece font-weight/text-decoration diretos num style="" (negrito/sublinhado), mas não tem tratamento para font-style — só reconhece itálico via tag semântica (<em>/<i>). Como o editor sempre aplica formatação com styleWithCSS ligado (editorCommands.ts), itálico vira font-style: italic num <span>, que a lib descartaria silenciosamente; contornado envolvendo esse conteúdo em <em> antes da conversão (wrapItalicElements em docx/generate.ts). Ver docx/generate.ts.
  • Importação (documentBuffer): mammoth (BSD-2) converte o corpo do .docx em HTML. Cabeçalho e rodapé (incluindo imagens embutidas) são recuperados à parte: o .docx é reaberto com JSZip, a parte word/header*.xml/footer*.xml é reempacotada como um word/document.xml sinté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 reconhece w: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 (restorePageFieldTokens em docx/pageFields.ts, inverso de injectPageFields). 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 com transformDocument (marca parágrafos alinhados com um styleId sintético) e reescrita do XML antes do envio (injeta um único w:rStyle sintético por run combinando cor/fonte/tamanho diretos, já que um run só tem um styleId), resolvidos via styleMap dinâmico + a linha estática u => u. O tamanho de exibição das imagens (wp:extent) também é descartado pelo mammoth — recuperado anexando um marcador com o tamanho em px ao descr do wp:docPr (único dado por imagem que o mammoth carrega até o HTML, como alt) e convertendo-o em style="width/height" na <img> depois da conversão. Ver docx/importDocx.ts.
  • Régua: arrasta margens de página (→ margens da seção do .docx) e recuos de parágrafo. Conversões px↔twips em docx/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 com data-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 em DocxEditor.tsx subtrai a altura acumulada dos espaçadores acima de cada bloco antes de chamar computePageLayout.
  • Recomputação: a cada input, mudança de margens/régua, import de .docx e load de imagem (listener em fase de captura), com throttle via requestAnimationFrame (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 via stripPageSpacers() — 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) + etiqueta Chip MUI ("Cabeçalho"/"Rodapé"), e o corpo esmaece (opacity: 0.45, via a flag derivada editingChrome).
  • 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" → prop onInsertPageField do Toolbar.tsxhandleInsertPageField em DocxEditor.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 (modelToDocx em docx/generate.ts): se hasPageFieldTokens() detecta tokens no header/footer do modelo, o .docx gerado pela lib é pós-processado com JSZip: em cada word/header*.xml e word/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 com RUN_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 por rebuildRun() + splitTextByTokens() como uma sequência ordenada de runs de texto e fldSimple, copiando o rPr original 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 do rPr podia "esticar" por cima de runs vizinhos quando havia um run anterior com formatação não vazia — o resultado era XML embaralhado (fldSimple aninhado, texto duplicado) e o Word recusava o arquivo. Há teste de regressão dedicado em docx/__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, o fldSimple declara xmlns:w no próprio elemento, exigido pelo atributo w: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() em DocxEditor.tsx troca 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ão contentEditable seleciona a imagem; clicar fora deseleciona. Em mode="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 de getBoundingClientRect() da imagem, dividindo pelo zoom (a coluna é escalada via transform: scale, mas a overlay vive em coordenadas sem zoom). A moldura tem pointer-events: none; só as alças são interativas.
  • Drag (startResize): durante o arraste só a largura é gravada (style.width em px, height: auto mantém a proporção); o delta do mouse é compensado pelo zoom e a largura é limitada entre 20px e a largura da região. No mouseup, a altura é fixada em px (style.height) e handleContentChanged() dispara repaginação + sincronização dos espelhos.
  • Caminho até o .docx (ida e volta): a exportação (@turbodocx/html-to-docx) lê o style="width/height" em px da imagem e converte px→EMU (1px = 9525 EMU) no wp:extent — o tamanho redimensionado vai para o arquivo. Na reimportação, o wp:extent é convertido de volta para style="width/height" em px (emuToPx em docx/units.ts), então o resize sobrevive ao ciclo salvar→reimportar. Testes em docx/__tests__/generate.test.ts (ida) e docx/__tests__/importDocx.test.ts (volta).
  • Deseleção automática: ao clicar fora, ao reimportar um .docx (docVersion muda) ou se a imagem sair do DOM (img.isConnected é checado em updateFrame()); 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 — MIT
  • mammoth — BSD-2-Clause
  • @mui/material / @mui/icons-material — MIT
  • buffer (feross/buffer, polyfill do global Buffer) — MIT
  • jszip (pós-processamento dos campos de página e recuperação de cabeçalho/rodapé na importação) — MIT

Limitações conhecidas (v1)

  • document e documentBuffer sã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 novo documentBuffer (que dispara reimportação).
  • Import de .docx recupera 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:themeColor sem w:val hex) ou w:val="auto" também não é recuperada.
  • O tamanho de exibição das imagens (wp:extent de wp:inline/wp:anchor) é recuperado na importação. Fora de escopo (melhor esforço): imagens VML legadas (w:pict) e drawings sem wp:docPr ou sem wp:extent importam 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.docx válido, texto/variável preservados, header/footer, contiguidade do {{token}}, imagem base64 → word/media, w:pgSz A4, save() funciona com globalThis.Buffer removido (polyfill), tokens [[PAGINA]]/[[TOTAL_PAGINAS]] viram fldSimple PAGE/NUMPAGES no footer (sem tokens vazados), documento sem tokens sai intacto, e caracterização do wp:extent: style="width/height" em px na imagem vira EMU no .docx (1px = 9525 EMU).
  • docx/__tests__/pageFields.test.tsinjectPageFields(): divisão do run preservando texto ao redor e rPr, múltiplos tokens no mesmo run, XML com e sem prefixo w: (declaração de xmlns: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 gerava fldSimple aninhado/texto duplicado).
  • docx/__tests__/pagination.test.tscomputePageLayout (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.tsxsave(), 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).