mgcode-docx-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MGCODE
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,277 @@
1
+ # mgcode-docx-editor
2
+
3
+ Editor WYSIWYG de **templates .docx** feito do zero (contentEditable + `document.execCommand`),
4
+ com a "casca" (toolbar, régua, zoom) em Material UI. Autora um documento com
5
+ cabeçalho/rodapé fixos e corpo com placeholders `{{variavel}}`, e exporta um
6
+ `.docx` válido para o fluxo LibreOffice → PDF.
7
+
8
+ ## Instalação
9
+
10
+ ```bash
11
+ npm install mgcode-docx-editor
12
+ ```
13
+
14
+ Peer dependencies (devem estar instaladas no projeto consumidor): `react`, `react-dom`,
15
+ `@mui/material`, `@mui/icons-material`, `@emotion/react`, `@emotion/styled`.
16
+
17
+ ## API
18
+
19
+ ```tsx
20
+ import { useRef } from 'react';
21
+ import { DocxEditor, DocxEditorHandle } from 'mgcode-docx-editor';
22
+
23
+ const editorRef = useRef<DocxEditorHandle>(null);
24
+
25
+ <DocxEditor
26
+ ref={editorRef}
27
+ documentBuffer={documentBuffer ?? undefined} // importar .docx existente
28
+ document={documentBuffer ? undefined : documentModel} // ou criar a partir de modelo
29
+ mode="editing" // 'editing' | 'viewing'
30
+ showToolbar
31
+ showRuler
32
+ showZoomControl
33
+ />;
34
+
35
+ // Salvar:
36
+ const buffer = await editorRef.current?.save(); // Promise<ArrayBuffer> (.docx)
37
+ ```
38
+
39
+ ### Props (`DocxEditorProps`)
40
+
41
+ | Prop | Tipo | Descrição |
42
+ | --- | --- | --- |
43
+ | `documentBuffer` | `ArrayBuffer?` | Importa um `.docx` existente. Tem precedência sobre `document`. |
44
+ | `document` | `DocumentModel?` | Cria/edita a partir de um modelo estruturado. |
45
+ | `mode` | `'editing' \| 'viewing'` | Editável ou somente leitura. |
46
+ | `showToolbar` | `boolean?` | Exibe a barra de ferramentas. |
47
+ | `showRuler` | `boolean?` | Exibe a régua funcional. |
48
+ | `showZoomControl` | `boolean?` | Exibe o controle de zoom. |
49
+
50
+ ### Handle imperativo (`DocxEditorHandle`, via `ref`)
51
+
52
+ - `save(): Promise<ArrayBuffer>` — serializa o conteúdo atual para `.docx`.
53
+ - `getModel(): DocumentModel` — retorna o modelo atual (`header`/`body`/`footer`/`variables`).
54
+
55
+ ### `DocumentModel`
56
+
57
+ ```ts
58
+ interface DocumentModel {
59
+ header?: string; // HTML do cabeçalho fixo (ex.: logo)
60
+ body: string; // HTML do corpo (contém {{variaveis}})
61
+ footer?: string; // HTML do rodapé fixo
62
+ variables?: string[]; // nomes para o menu "Inserir variável"
63
+ }
64
+ ```
65
+
66
+ ## Arquitetura
67
+
68
+ - **Editor**: três regiões `contentEditable` (header/body/footer) + `execCommand`.
69
+ - **Página**: **A4** (794×1123 px a 96dpi na tela; 11906×16838 twips no export,
70
+ via `PAGE_SIZE_A4_TWIPS` em `docx/generate.ts`).
71
+ - **Exportação** (`save()`): `@turbodocx/html-to-docx` (MIT) converte o HTML das
72
+ regiões em `.docx`, com cabeçalho/rodapé como partes do Word e margens da régua
73
+ como `w:pgMar`. Ver `docx/generate.ts`.
74
+ - **Importação** (`documentBuffer`): `mammoth` (BSD-2) converte o `.docx` em HTML.
75
+ Ver `docx/importDocx.ts`.
76
+ - **Régua**: arrasta margens de página (→ margens da seção do `.docx`) e recuos de
77
+ parágrafo. Conversões px↔twips em `docx/units.ts`.
78
+
79
+ ### Polyfill de `Buffer` (imagens no browser)
80
+
81
+ O bundle browser da `@turbodocx/html-to-docx` usa o global `Buffer` do Node para
82
+ decodificar imagens base64; CRA/webpack 5 não polyfilla globais do Node, o que
83
+ causava `"Buffer is not defined"` ao salvar documentos com imagens. Correção:
84
+ dependência `buffer` (feross/buffer, MIT) e `ensureBufferGlobal()` chamado no
85
+ início de `modelToDocx()` em `docx/generate.ts` — atribui `globalThis.Buffer` ao
86
+ polyfill se indefinido. É feito em tempo de chamada (não de import), então
87
+ funciona independentemente da ordem de carregamento e não sobrescreve um
88
+ `Buffer` já existente (Node/Jest).
89
+
90
+ ### Paginação visual (estilo Word)
91
+
92
+ O editor renderiza N caixas "papel" A4 posicionadas absolutamente
93
+ (`data-page-index` em cada uma, para testes/automação), mas o corpo continua
94
+ sendo **um único `contentEditable` contínuo** atravessando todas as páginas —
95
+ o DOM nunca é dividido, então cursor, undo e `execCommand` seguem funcionando.
96
+
97
+ ```
98
+ ┌─ página 0 ──────────────┐
99
+ │ header REAL (editável) │ ← 48px da borda (distância do Word)
100
+ │ corpo contínuo… │
101
+ │ footer REAL (editável) │
102
+ ├─ gap 24px ──────────────┤
103
+ ┌─ página 1+ ─────────────┐
104
+ │ espelho header (RO) │ ← aria-hidden, dangerouslySetInnerHTML
105
+ │ …corpo continua │ sincronizado a cada input
106
+ │ espelho footer (RO) │
107
+ └─────────────────────────┘
108
+ ```
109
+
110
+ - **Quebra por bloco**: `docx/pagination.ts` (módulo puro, sem DOM no cálculo).
111
+ `computePageLayout(blocks, {usableHeightPx, interPageGapPx})` decide, para cada
112
+ bloco top-level do corpo, se ele cruzaria a zona do rodapé; se sim (e couber
113
+ inteiro numa página), um **espaçador** não-editável é inserido antes dele
114
+ (`createSpacerElement()`: div com `data-page-spacer`, `contenteditable=false`),
115
+ empurrando-o para a próxima página.
116
+ - **Contrato de coordenadas**: `BlockBox.top` é em coordenadas **sem
117
+ espaçadores**; a medição do DOM em `DocxEditor.tsx` subtrai a altura acumulada
118
+ dos espaçadores acima de cada bloco antes de chamar `computePageLayout`.
119
+ - **Recomputação**: a cada input, mudança de margens/régua, import de `.docx` e
120
+ `load` de imagem (listener em fase de captura), com throttle via
121
+ `requestAnimationFrame` (`schedulePagination`). O ciclo é auto-corrigível:
122
+ remove todos os espaçadores e reinsere do zero.
123
+ - **Header/footer editáveis** vivem apenas na página 1, nas áreas de margem;
124
+ páginas 2+ mostram espelhos somente leitura sincronizados a cada edição.
125
+ - **Sem rastro no modelo**: `buildModel()`/`save()` removem os espaçadores via
126
+ `stripPageSpacers()` — a paginação é puramente visual e o round-trip do HTML
127
+ do corpo é exato (há teste para isso).
128
+
129
+ ### Indicador de região em edição (estilo Word)
130
+
131
+ `DocxEditor.tsx` mantém o estado `focusedRegion: 'header' | 'body' | 'footer' | null`
132
+ (atualizado em `onRegionFocus`/`onRegionBlur`):
133
+
134
+ - Ao focar o **cabeçalho ou rodapé**, a região ganha contorno tracejado
135
+ (`outline: 1px dashed`, cor primária) + etiqueta `Chip` MUI ("Cabeçalho"/"Rodapé"),
136
+ e o **corpo esmaece** (`opacity: 0.45`, via a flag derivada `editingChrome`).
137
+ - Ao editar o **corpo**, header/footer reais e os espelhos das páginas 2+ esmaecem
138
+ levemente (`opacity: 0.55`).
139
+
140
+ É indicação puramente visual — não altera modelo nem exportação.
141
+
142
+ ### Variáveis `{{...}}`
143
+
144
+ São inseridas como **texto literal contíguo** (via Range API, não `execCommand`),
145
+ garantindo que o LibreOffice/docxtemplater consiga substituí-las — o token nunca é
146
+ quebrado entre runs do Word. Há teste dedicado para isso em
147
+ `docx/__tests__/generate.test.ts`.
148
+
149
+ ### Campos de página do Word (`[[PAGINA]]` / `[[TOTAL_PAGINAS]]`)
150
+
151
+ Módulo `docx/pageFields.ts`. O usuário insere **tokens literais** `[[PAGINA]]` e
152
+ `[[TOTAL_PAGINAS]]` que, na exportação, viram campos nativos do Word
153
+ (PAGE/NUMPAGES) que o Word/LibreOffice atualizam sozinhos.
154
+
155
+ - **Sintaxe `[[...]]` é deliberada**: diferente de `{{...}}` para o docxtemplater
156
+ **nunca** tentar substituí-los no pipeline.
157
+ - **Inserção**: menu "Variável" da toolbar (agora **sempre visível**, mesmo sem
158
+ `variables`), itens "Nº da página" e "Total de páginas" → prop
159
+ `onInsertPageField` do `Toolbar.tsx` → `handleInsertPageField` em
160
+ `DocxEditor.tsx`, que insere via Range API (contíguo, como as variáveis).
161
+ Sem região focada, o fallback é o **rodapé** (onde campos de página fazem sentido).
162
+ - **Exportação** (`modelToDocx` em `docx/generate.ts`): se `hasPageFieldTokens()`
163
+ detecta tokens no header/footer do modelo, o `.docx` gerado pela lib é
164
+ pós-processado com **JSZip**: em cada `word/header*.xml` e `word/footer*.xml`,
165
+ `injectPageFields()` substitui cada token por
166
+ `<w:fldSimple w:instr=" PAGE ">` / `" NUMPAGES "`. Sem tokens, o buffer sai
167
+ intacto (nenhum reprocessamento do zip).
168
+ - **Processamento run a run** (invariante crítico): `injectPageFields()` itera
169
+ o XML com `RUN_PATTERN`, que casa **um `<r>…</r>` inteiro por vez** — o lazy
170
+ até o primeiro `</r>` nunca atravessa runs vizinhos, porque runs não aninham
171
+ em OOXML. Cada run com token é reconstruído por `rebuildRun()` +
172
+ `splitTextByTokens()` como uma sequência ordenada de runs de texto e
173
+ `fldSimple`, copiando o `rPr` original para dentro de cada pedaço — a
174
+ formatação (negrito, fonte etc.) é preservada. **Por que run a run**: a
175
+ implementação antiga usava um único regex sobre o XML inteiro cujo grupo
176
+ lazy do `rPr` podia "esticar" por cima de runs vizinhos quando havia um run
177
+ anterior com formatação não vazia — o resultado era XML embaralhado
178
+ (`fldSimple` aninhado, texto duplicado) e o Word **recusava o arquivo**. Há
179
+ teste de regressão dedicado em `docx/__tests__/pageFields.test.ts`.
180
+ - **Dois formatos de OOXML**: `RUN_PATTERN`/`rebuildRun()` aceitam XML
181
+ prefixado (`<w:r>`, como no document.xml) **e** em namespace default sem
182
+ prefixo (`<r>`, formato dos header/footer gerados pela
183
+ `@turbodocx/html-to-docx`). No caso sem prefixo, o `fldSimple` declara
184
+ `xmlns:w` no próprio elemento, exigido pelo atributo `w:instr`.
185
+ - **Valor em cache**: o campo carrega o texto "1", exibido até o Word/LibreOffice
186
+ atualizar os campos.
187
+ - **Preview no editor**: nos espelhos das páginas 2+, `withPagePreview()` em
188
+ `DocxEditor.tsx` troca os tokens pelos números reais ("Página 2 de 3"); na
189
+ página 1 (regiões editáveis) os tokens ficam visíveis como texto.
190
+
191
+ ### Redimensionamento de imagens
192
+
193
+ Implementado em `DocxEditor.tsx` (estado `selectedImg` + `frame`), sem tocar no
194
+ `execCommand`:
195
+
196
+ - **Seleção**: delegação de clique na coluna de páginas (`handleColumnClick`) —
197
+ clicar num `<img>` dentro de qualquer região `contentEditable` seleciona a
198
+ imagem; clicar fora deseleciona. Em `mode="viewing"` não há seleção.
199
+ - **Moldura**: uma overlay absoluta sobre a coluna de páginas
200
+ (`data-testid="image-resize-frame"`) com 4 alças de canto
201
+ (`data-testid="image-resize-handle"`). `updateFrame()` posiciona a moldura a
202
+ partir de `getBoundingClientRect()` da imagem, **dividindo pelo zoom** (a
203
+ coluna é escalada via `transform: scale`, mas a overlay vive em coordenadas
204
+ sem zoom). A moldura tem `pointer-events: none`; só as alças são interativas.
205
+ - **Drag** (`startResize`): durante o arraste só a **largura** é gravada
206
+ (`style.width` em px, `height: auto` mantém a proporção); o delta do mouse é
207
+ compensado pelo zoom e a largura é limitada entre 20px e a largura da região.
208
+ No `mouseup`, a altura é **fixada em px** (`style.height`) e
209
+ `handleContentChanged()` dispara repaginação + sincronização dos espelhos.
210
+ - **Caminho até o `.docx`**: a exportação (`@turbodocx/html-to-docx`) lê o
211
+ `style="width/height"` em px da imagem e converte px→EMU (1px = 9525 EMU) no
212
+ `wp:extent` — o tamanho redimensionado vai para o arquivo. Há teste de
213
+ caracterização em `docx/__tests__/generate.test.ts`.
214
+ - **Deseleção automática**: ao clicar fora, ao reimportar um `.docx`
215
+ (`docVersion` muda) ou se a imagem sair do DOM (`img.isConnected` é checado
216
+ em `updateFrame()`); a moldura também é reposicionada quando layout, margens
217
+ ou espelhos mudam.
218
+
219
+ ## Licenças das dependências (todas permissivas / uso comercial livre)
220
+
221
+ - `@turbodocx/html-to-docx` — MIT
222
+ - `mammoth` — BSD-2-Clause
223
+ - `@mui/material` / `@mui/icons-material` — MIT
224
+ - `buffer` (feross/buffer, polyfill do global `Buffer`) — MIT
225
+ - `jszip` (pós-processamento dos campos de página no `.docx`) — MIT
226
+
227
+ ## Limitações conhecidas (v1)
228
+
229
+ - `document` e `documentBuffer` são valores **iniciais** (componente não controlado,
230
+ como editores em geral). Trocar essas props depois de montado **não** re-sincroniza
231
+ o conteúdo já editado, exceto por um novo `documentBuffer` (que dispara reimportação).
232
+ - Import de `.docx` externo via `mammoth` recupera apenas o **corpo**; cabeçalho e
233
+ rodapé de arquivos de terceiros não são reconstruídos. Para templates criados por
234
+ este editor, o `DocumentModel` é a fonte de verdade.
235
+ - `document.execCommand` é depreciado (porém suportado em todos os browsers atuais);
236
+ é a escolha deliberada para um editor "do zero" sem framework de edição.
237
+ - Colar conteúdo do Word ainda não é sanitizado (escopo futuro).
238
+ - **Paginação por bloco apenas**: um parágrafo nunca é dividido no meio entre
239
+ páginas — o bloco inteiro é empurrado. Blocos mais altos que a área útil de
240
+ uma página **não** são divididos e transbordam sobre a página seguinte.
241
+ - Os espelhos de cabeçalho/rodapé nas páginas 2+ são somente leitura; a edição
242
+ acontece nas regiões reais da página 1.
243
+
244
+ ## Testes
245
+
246
+ - `docx/__tests__/units.test.ts` — conversões de unidade.
247
+ - `docx/__tests__/generate.test.ts` — `.docx` válido, texto/variável preservados,
248
+ header/footer, contiguidade do `{{token}}`, imagem base64 → `word/media`,
249
+ `w:pgSz` A4, `save()` funciona com `globalThis.Buffer` removido (polyfill),
250
+ tokens `[[PAGINA]]`/`[[TOTAL_PAGINAS]]` viram `fldSimple` PAGE/NUMPAGES no
251
+ footer (sem tokens vazados), documento sem tokens sai intacto, e
252
+ caracterização do `wp:extent`: `style="width/height"` em px na imagem vira
253
+ EMU no `.docx` (1px = 9525 EMU).
254
+ - `docx/__tests__/pageFields.test.ts` — `injectPageFields()`: divisão do run
255
+ preservando texto ao redor e `rPr`, múltiplos tokens no mesmo run, XML com e
256
+ sem prefixo `w:` (declaração de `xmlns:w`), XML sem tokens inalterado,
257
+ `hasPageFieldTokens()`, e **regressão do docx corrompido**: "não embaralha o
258
+ XML quando há run anterior com formatação" (o regex antigo atravessava runs
259
+ e gerava `fldSimple` aninhado/texto duplicado).
260
+ - `docx/__tests__/pagination.test.ts` — `computePageLayout` (quebras, espaçadores,
261
+ blocos maiores que a página, contagem de páginas).
262
+ - `docx/__tests__/importDocx.test.ts` — round-trip de importação.
263
+ - `__tests__/DocxEditor.test.tsx` — `save()`, render, inserção de variável, modo
264
+ leitura, `getModel()` remove espaçadores com round-trip exato do corpo,
265
+ etiqueta de foco (Chip "Cabeçalho"/"Rodapé" ao focar a região), inserção dos
266
+ campos de página pelo menu "Variável", seleção de imagem (clique mostra a
267
+ moldura com 4 alças; clique fora deseleciona).
268
+
269
+ A formatação via `execCommand`, o arraste da régua e a paginação visual
270
+ (múltiplas páginas, rodapé ancorado no pé da página, salvar com imagem sem erro
271
+ de `Buffer`) são validados em **browser real** via script Playwright, pois o
272
+ jsdom não implementa `execCommand` nem layout real. Também validados no browser:
273
+ etiquetas e esmaecimento de foco, preview "Página 2 de 2" nos espelhos,
274
+ `fldSimple` no `footer.xml` do `.docx` salvo (1 PAGE + 1 NUMPAGES, runs
275
+ balanceados, sem aninhamento — todas as partes XML do zip validadas com parser
276
+ rigoroso .NET) e o drag de resize de imagem ponta a ponta (arrastar a alça
277
+ altera a largura e o `wp:extent` exportado corresponde ao tamanho arrastado).
@@ -0,0 +1,40 @@
1
+ import * as react from 'react';
2
+
3
+ interface DocumentModel {
4
+ /** HTML do cabeçalho fixo (ex.: logo). Opcional. */
5
+ header?: string;
6
+ /** HTML do corpo do documento. Contém os placeholders {{variavel}}. */
7
+ body: string;
8
+ /** HTML do rodapé fixo. Opcional. */
9
+ footer?: string;
10
+ /** Nomes de variáveis disponíveis para inserir (ex.: ["patientName"]). */
11
+ variables?: string[];
12
+ }
13
+ /** Margens da página em twips (1/1440 pol), como o Word/OOXML espera. */
14
+ interface PageMarginsTwips {
15
+ top: number;
16
+ right: number;
17
+ bottom: number;
18
+ left: number;
19
+ }
20
+ type DocxEditorMode = 'editing' | 'viewing';
21
+ interface DocxEditorProps {
22
+ /** Importa um .docx existente (tem precedência sobre `document`). */
23
+ documentBuffer?: ArrayBuffer;
24
+ /** Cria/edita a partir de um modelo estruturado. */
25
+ document?: DocumentModel;
26
+ mode: DocxEditorMode;
27
+ showToolbar?: boolean;
28
+ showRuler?: boolean;
29
+ showZoomControl?: boolean;
30
+ }
31
+ interface DocxEditorHandle {
32
+ /** Serializa o conteúdo atual para um .docx (ArrayBuffer). */
33
+ save(): Promise<ArrayBuffer>;
34
+ /** Retorna o modelo atual do editor. */
35
+ getModel(): DocumentModel;
36
+ }
37
+
38
+ declare const DocxEditor: react.ForwardRefExoticComponent<DocxEditorProps & react.RefAttributes<DocxEditorHandle>>;
39
+
40
+ export { type DocumentModel, DocxEditor, type DocxEditorHandle, type DocxEditorMode, type DocxEditorProps, type PageMarginsTwips };
@@ -0,0 +1,40 @@
1
+ import * as react from 'react';
2
+
3
+ interface DocumentModel {
4
+ /** HTML do cabeçalho fixo (ex.: logo). Opcional. */
5
+ header?: string;
6
+ /** HTML do corpo do documento. Contém os placeholders {{variavel}}. */
7
+ body: string;
8
+ /** HTML do rodapé fixo. Opcional. */
9
+ footer?: string;
10
+ /** Nomes de variáveis disponíveis para inserir (ex.: ["patientName"]). */
11
+ variables?: string[];
12
+ }
13
+ /** Margens da página em twips (1/1440 pol), como o Word/OOXML espera. */
14
+ interface PageMarginsTwips {
15
+ top: number;
16
+ right: number;
17
+ bottom: number;
18
+ left: number;
19
+ }
20
+ type DocxEditorMode = 'editing' | 'viewing';
21
+ interface DocxEditorProps {
22
+ /** Importa um .docx existente (tem precedência sobre `document`). */
23
+ documentBuffer?: ArrayBuffer;
24
+ /** Cria/edita a partir de um modelo estruturado. */
25
+ document?: DocumentModel;
26
+ mode: DocxEditorMode;
27
+ showToolbar?: boolean;
28
+ showRuler?: boolean;
29
+ showZoomControl?: boolean;
30
+ }
31
+ interface DocxEditorHandle {
32
+ /** Serializa o conteúdo atual para um .docx (ArrayBuffer). */
33
+ save(): Promise<ArrayBuffer>;
34
+ /** Retorna o modelo atual do editor. */
35
+ getModel(): DocumentModel;
36
+ }
37
+
38
+ declare const DocxEditor: react.ForwardRefExoticComponent<DocxEditorProps & react.RefAttributes<DocxEditorHandle>>;
39
+
40
+ export { type DocumentModel, DocxEditor, type DocxEditorHandle, type DocxEditorMode, type DocxEditorProps, type PageMarginsTwips };