@lotics/docx 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.
Files changed (107) hide show
  1. package/package.json +40 -0
  2. package/src/fixtures/.gitkeep +0 -0
  3. package/src/fixtures/lotics_generated_contract.docx +0 -0
  4. package/src/fonts/bundled.ts +123 -0
  5. package/src/fonts/registry.test.ts +233 -0
  6. package/src/fonts/registry.ts +219 -0
  7. package/src/fonts/types.ts +83 -0
  8. package/src/index.ts +16 -0
  9. package/src/layout/engine.test.ts +430 -0
  10. package/src/layout/engine.ts +566 -0
  11. package/src/layout/page_geometry.ts +43 -0
  12. package/src/layout/types.ts +159 -0
  13. package/src/load.test.ts +144 -0
  14. package/src/load.ts +142 -0
  15. package/src/model/default_numbering.ts +101 -0
  16. package/src/model/default_styles.ts +201 -0
  17. package/src/model/numbering_table.ts +52 -0
  18. package/src/model/properties.ts +328 -0
  19. package/src/model/sections.ts +94 -0
  20. package/src/model/style_resolution.test.ts +219 -0
  21. package/src/model/style_resolution.ts +113 -0
  22. package/src/model/style_table.ts +22 -0
  23. package/src/model/theme.ts +156 -0
  24. package/src/model/types.ts +55 -0
  25. package/src/parse/drawing.ts +157 -0
  26. package/src/parse/font_table.ts +132 -0
  27. package/src/parse/footnotes.ts +60 -0
  28. package/src/parse/header_footer.test.ts +264 -0
  29. package/src/parse/header_footer.ts +66 -0
  30. package/src/parse/numbering.ts +187 -0
  31. package/src/parse/parser.ts +184 -0
  32. package/src/parse/relationships.ts +83 -0
  33. package/src/parse/sections.test.ts +192 -0
  34. package/src/parse/sections.ts +182 -0
  35. package/src/parse/styles.ts +149 -0
  36. package/src/parse/theme.test.ts +86 -0
  37. package/src/parse/theme.ts +112 -0
  38. package/src/pm/bubble_menu.ts +117 -0
  39. package/src/pm/commands.test.ts +185 -0
  40. package/src/pm/commands.ts +697 -0
  41. package/src/pm/commands_insert.test.ts +183 -0
  42. package/src/pm/docx_to_pm.test.ts +330 -0
  43. package/src/pm/docx_to_pm.ts +643 -0
  44. package/src/pm/drag_handle.ts +166 -0
  45. package/src/pm/format_painter.test.ts +91 -0
  46. package/src/pm/format_painter.ts +109 -0
  47. package/src/pm/header_footer_doc.ts +24 -0
  48. package/src/pm/hyperlinks.test.ts +234 -0
  49. package/src/pm/image_registry.test.ts +81 -0
  50. package/src/pm/image_registry.ts +100 -0
  51. package/src/pm/images.test.ts +257 -0
  52. package/src/pm/link_popover.ts +159 -0
  53. package/src/pm/mark_commands.ts +60 -0
  54. package/src/pm/marks.ts +169 -0
  55. package/src/pm/nodes.ts +258 -0
  56. package/src/pm/numbering.test.ts +210 -0
  57. package/src/pm/numbering_plugin.test.ts +71 -0
  58. package/src/pm/numbering_plugin.ts +96 -0
  59. package/src/pm/outline.ts +41 -0
  60. package/src/pm/page_break.test.ts +80 -0
  61. package/src/pm/page_layout.test.ts +87 -0
  62. package/src/pm/pagination_plugin.test.ts +155 -0
  63. package/src/pm/pagination_plugin.ts +590 -0
  64. package/src/pm/phase5.test.ts +271 -0
  65. package/src/pm/phase6.test.ts +215 -0
  66. package/src/pm/placeholder_plugin.ts +24 -0
  67. package/src/pm/plugins.ts +91 -0
  68. package/src/pm/pm_to_docx.ts +0 -0
  69. package/src/pm/roundtrip.test.ts +332 -0
  70. package/src/pm/schema.test.ts +188 -0
  71. package/src/pm/schema.ts +79 -0
  72. package/src/pm/search.ts +46 -0
  73. package/src/pm/table_attrs.ts +48 -0
  74. package/src/pm/table_borders.test.ts +117 -0
  75. package/src/pm/table_borders.ts +130 -0
  76. package/src/pm/table_convert.test.ts +221 -0
  77. package/src/pm/table_convert.ts +541 -0
  78. package/src/pm/table_decorations.ts +132 -0
  79. package/src/pm/table_handles.ts +163 -0
  80. package/src/pm/template_marker.ts +47 -0
  81. package/src/pm/template_plugin.ts +65 -0
  82. package/src/pm/templates.test.ts +162 -0
  83. package/src/render/clipboard.test.ts +115 -0
  84. package/src/render/clipboard.ts +200 -0
  85. package/src/render/editable_view.test.ts +173 -0
  86. package/src/render/footnotes_view.ts +94 -0
  87. package/src/render/header_footer_view.ts +95 -0
  88. package/src/render/link_mark_view.ts +26 -0
  89. package/src/render/media_resolver.ts +61 -0
  90. package/src/render/node_views.ts +296 -0
  91. package/src/render/numbering_counter.ts +149 -0
  92. package/src/render/page_chrome.test.ts +262 -0
  93. package/src/render/page_chrome.ts +343 -0
  94. package/src/render/page_styles.ts +234 -0
  95. package/src/render/paragraph_view.test.ts +162 -0
  96. package/src/render/paragraph_view.ts +141 -0
  97. package/src/render/ruler.ts +110 -0
  98. package/src/render/style_registry.ts +33 -0
  99. package/src/render/table_dom.test.ts +171 -0
  100. package/src/render/table_dom.ts +288 -0
  101. package/src/render/units.ts +18 -0
  102. package/src/render/view.test.ts +165 -0
  103. package/src/render/view.ts +607 -0
  104. package/src/roundtrip.test.ts +179 -0
  105. package/src/serialize/default_parts.ts +128 -0
  106. package/src/serialize/header_footer_pm.ts +82 -0
  107. package/src/serialize/serializer.ts +114 -0
@@ -0,0 +1,607 @@
1
+ import { EditorState, type Plugin } from "prosemirror-state";
2
+ import { EditorView, type NodeView } from "prosemirror-view";
3
+ import type { Node as PMNode } from "prosemirror-model";
4
+ import type { FontRegistry } from "../fonts/types";
5
+ import type { Section } from "../model/sections";
6
+ import type { RelationshipMap } from "../parse/relationships";
7
+ import type { HeaderFooterRegistry } from "../parse/header_footer";
8
+ import type { FootnoteRegistry } from "../parse/footnotes";
9
+ import { renderParagraph } from "./table_dom";
10
+ import { docxSchema } from "../pm/schema";
11
+ import { buildEditorPlugins, buildReadOnlyPlugins } from "../pm/plugins";
12
+ import type { NumberingTable } from "../model/numbering_table";
13
+ import {
14
+ imageRegistryPlugin,
15
+ type ImageRegistry,
16
+ } from "../pm/image_registry";
17
+ import { buildLinkMarkView } from "./link_mark_view";
18
+ import {
19
+ buildImageInlineView,
20
+ fieldInlineView,
21
+ opaqueBlockView,
22
+ opaqueInlineView,
23
+ sectionBreakView,
24
+ } from "./node_views";
25
+ import { buildParagraphView } from "./paragraph_view";
26
+ import type { ThemeTable } from "../model/theme";
27
+ import {
28
+ NULL_MEDIA_RESOLVER,
29
+ type MediaResolver,
30
+ } from "./media_resolver";
31
+ import { buildClipboardHooks } from "./clipboard";
32
+ import {
33
+ EDITOR_BASE_CSS,
34
+ fontFaceStylesheet,
35
+ } from "./page_styles";
36
+ import { buildRuler, RULER_CSS } from "./ruler";
37
+ import {
38
+ createPageChromeManager,
39
+ DEFAULT_PAGE_GAP_PX,
40
+ type HeaderFooterFactory,
41
+ type PageChromeManager,
42
+ } from "./page_chrome";
43
+ import {
44
+ getPaginationState,
45
+ paginationPlugin,
46
+ } from "../pm/pagination_plugin";
47
+ import { computeSectionGeometries } from "../layout/page_geometry";
48
+ import type { LayoutResult } from "../layout/types";
49
+ import { dxaToPx } from "./units";
50
+ import { placeholderPlugin } from "../pm/placeholder_plugin";
51
+ import { resolveInternalPartPath } from "../parse/relationships";
52
+ import { headerFooterFragmentToPm } from "../pm/header_footer_doc";
53
+ import { pmDocToHeaderFooterXml } from "../serialize/header_footer_pm";
54
+ import { keymap } from "prosemirror-keymap";
55
+ import { history, redo, undo } from "prosemirror-history";
56
+ import { baseKeymap, toggleMark } from "prosemirror-commands";
57
+ import { dropCursor } from "prosemirror-dropcursor";
58
+ import { gapCursor } from "prosemirror-gapcursor";
59
+
60
+ export type ViewMode = "read-only" | "editable";
61
+
62
+ export type DocxViewOptions = {
63
+ fontRegistry: FontRegistry;
64
+ sections: readonly Section[];
65
+ relationships?: RelationshipMap;
66
+ mediaResolver?: MediaResolver;
67
+ imageRegistry?: ImageRegistry;
68
+ numberingTable?: NumberingTable | null;
69
+ themeTable?: ThemeTable | null;
70
+ headerFooterRegistry?: HeaderFooterRegistry;
71
+ footnoteRegistry?: FootnoteRegistry;
72
+ mode?: ViewMode;
73
+ onChange?: (doc: PMNode) => void;
74
+ onPageCountChange?: (pageCount: number) => void;
75
+ plugins?: readonly Plugin[];
76
+ };
77
+
78
+ export type ReadOnlyViewOptions = DocxViewOptions;
79
+
80
+ export type PaginationHandle = {
81
+ /**
82
+ * Most recent page count from the layout engine. Returns 1 until the
83
+ * first layout pass completes (one rAF after mount).
84
+ */
85
+ getPageCount(): number;
86
+ /**
87
+ * Most recent layout result. Null until the first layout pass completes.
88
+ */
89
+ getLayout(): LayoutResult | null;
90
+ };
91
+
92
+ export type ReadOnlyView = {
93
+ destroy(): void;
94
+ editorView: EditorView;
95
+ canvasElement: HTMLElement;
96
+ canvasInner: HTMLElement;
97
+ styleElement: HTMLStyleElement;
98
+ pagination: PaginationHandle;
99
+ getDocument(): PMNode;
100
+ setZoom(zoom: number): void;
101
+ /**
102
+ * Apply any in-place header/footer edits made through the editable
103
+ * header/footer DOMs to a clone of the parts map. Caller owns the
104
+ * resulting map and writes it through pmToDocx → serializeDocx.
105
+ */
106
+ commitHeaderFooterEdits(parts: ReadonlyMap<string, Uint8Array>): Map<string, Uint8Array>;
107
+ };
108
+
109
+ export function createReadOnlyView(
110
+ host: HTMLElement,
111
+ pmDoc: PMNode,
112
+ options: DocxViewOptions,
113
+ ): ReadOnlyView {
114
+ const styleElement = document.createElement("style");
115
+ styleElement.setAttribute("data-docx-editor-styles", "true");
116
+ styleElement.textContent =
117
+ EDITOR_BASE_CSS +
118
+ "\n" +
119
+ RULER_CSS +
120
+ "\n" +
121
+ fontFaceStylesheet(options.fontRegistry);
122
+ host.appendChild(styleElement);
123
+
124
+ const firstSection = options.sections[0]?.properties;
125
+ if (!firstSection) {
126
+ throw new Error("createReadOnlyView requires at least one section");
127
+ }
128
+ const sectionGeometries = computeSectionGeometries(options.sections);
129
+ const firstGeometry = sectionGeometries[0];
130
+
131
+ const canvasElement = document.createElement("div");
132
+ canvasElement.className = "docx-canvas";
133
+ canvasElement.setAttribute("data-docx-canvas", "true");
134
+ host.appendChild(canvasElement);
135
+
136
+ const canvasInner = document.createElement("div");
137
+ canvasInner.className = "docx-canvas-inner";
138
+ canvasInner.style.position = "relative";
139
+ canvasInner.style.width = `${firstGeometry.pageWidth}px`;
140
+ canvasElement.appendChild(canvasInner);
141
+
142
+ const ruler = buildRuler(firstSection);
143
+ canvasInner.appendChild(ruler.element);
144
+
145
+ // Each page rectangle in the chrome layer aligns vertically with a region
146
+ // of the editor's flowing content. We position the editor content
147
+ // absolutely *over* the layer and use widget decorations between pages
148
+ // to push subsequent paragraphs into the next page's body slot.
149
+ const stackRoot = document.createElement("div");
150
+ stackRoot.className = "docx-page-stack";
151
+ stackRoot.style.position = "relative";
152
+ stackRoot.style.width = `${firstGeometry.pageWidth}px`;
153
+ canvasInner.appendChild(stackRoot);
154
+
155
+ // When a header/footer sub-view takes focus, the body editor must yield
156
+ // both pointer interaction *and* keyboard editability. CSS pointer-events
157
+ // alone doesn't block keypresses — we toggle this flag and the body's
158
+ // `editable()` callback reads it.
159
+ let subViewFocused = false;
160
+ const setBodyDimmed = (dimmed: boolean) => {
161
+ stackRoot.classList.toggle("docx-page-stack--body-dimmed", dimmed);
162
+ subViewFocused = dimmed;
163
+ };
164
+ type EditableHeaderFooter = {
165
+ view: EditorView;
166
+ kind: "hdr" | "ftr";
167
+ partPath: string;
168
+ };
169
+ // Keyed by partPath, not relationshipId — two relationship ids in the
170
+ // same section can target the same part (rare but legal). The first
171
+ // mounted view wins; duplicates are torn down.
172
+ const editableHeaderFooters = new Map<string, EditableHeaderFooter>();
173
+ const mode: ViewMode = options.mode ?? "read-only";
174
+ const isEditable = mode === "editable";
175
+
176
+ // Editable header/footer mounter — used only for page 1 in editable mode.
177
+ // Pages 2..N render the same fragment as read-only DOM via the default
178
+ // chrome path. This mirrors Word's UX: the user edits "the header," not
179
+ // "header on page 3."
180
+ const mountHeaderFooterPm = (
181
+ fragment: import("../parse/header_footer").HeaderFooterFragment,
182
+ relationshipId: string | null,
183
+ kind: "hdr" | "ftr",
184
+ ): HTMLElement => {
185
+ const wrapper = document.createElement("div");
186
+ wrapper.className = "docx-header-footer docx-header-footer--editable";
187
+ wrapper.setAttribute(`data-docx-${kind === "hdr" ? "header" : "footer"}`, "true");
188
+ const headerPmDoc = headerFooterFragmentToPm(fragment, {
189
+ styleTable: null,
190
+ numberingTable: options.numberingTable ?? null,
191
+ });
192
+ const state = EditorState.create({
193
+ schema: docxSchema,
194
+ doc: headerPmDoc,
195
+ plugins: [
196
+ history(),
197
+ keymap({ "Mod-z": undo, "Shift-Mod-z": redo, "Mod-y": redo }),
198
+ keymap({
199
+ "Mod-b": toggleMark(docxSchema.marks.bold),
200
+ "Mod-i": toggleMark(docxSchema.marks.italic),
201
+ "Mod-u": toggleMark(docxSchema.marks.underline),
202
+ }),
203
+ keymap(baseKeymap),
204
+ dropCursor(),
205
+ gapCursor(),
206
+ ],
207
+ });
208
+ const subView = new EditorView(wrapper, {
209
+ state,
210
+ editable: () => true,
211
+ attributes: {
212
+ class: "docx-header-footer-content",
213
+ spellcheck: "true",
214
+ },
215
+ });
216
+ wrapper.addEventListener("focusin", () => setBodyDimmed(true));
217
+ wrapper.addEventListener("focusout", (e) => {
218
+ const next = e.relatedTarget as HTMLElement | null;
219
+ if (!next || !wrapper.contains(next)) setBodyDimmed(false);
220
+ });
221
+ if (relationshipId && options.relationships) {
222
+ const rel = options.relationships.get(relationshipId);
223
+ if (rel) {
224
+ const partPath = resolveInternalPartPath(rel.target);
225
+ if (editableHeaderFooters.has(partPath)) {
226
+ subView.destroy();
227
+ wrapper.remove();
228
+ } else {
229
+ editableHeaderFooters.set(partPath, {
230
+ view: subView,
231
+ kind,
232
+ partPath,
233
+ });
234
+ }
235
+ }
236
+ }
237
+ return wrapper;
238
+ };
239
+
240
+ const headerFooterFactory: HeaderFooterFactory = ({
241
+ pageNumber,
242
+ kind,
243
+ fragment,
244
+ relationshipId,
245
+ }) => {
246
+ // Only the first page gets an editable PM mini-view in editable mode.
247
+ // Pages 2..N fall through to the chrome manager's default read-only
248
+ // DOM rendering, which avoids the multi-view selection/coordination
249
+ // problem.
250
+ if (!isEditable) return null;
251
+ if (pageNumber !== 1) return null;
252
+ return mountHeaderFooterPm(fragment, relationshipId, kind);
253
+ };
254
+
255
+ const pageChrome: PageChromeManager = createPageChromeManager({
256
+ host: stackRoot,
257
+ sections: options.sections,
258
+ sectionGeometries,
259
+ headerFooterRegistry: options.headerFooterRegistry ?? null,
260
+ pageGapPx: DEFAULT_PAGE_GAP_PX,
261
+ renderHeaderFooter: headerFooterFactory,
262
+ });
263
+
264
+ const numberingTable = options.numberingTable ?? null;
265
+ const basePlugins =
266
+ options.plugins ??
267
+ (isEditable
268
+ ? buildEditorPlugins({ numberingTable })
269
+ : buildReadOnlyPlugins({ numberingTable }));
270
+
271
+ const footnoteRegistry = options.footnoteRegistry ?? null;
272
+
273
+ const paginationPluginInstance = paginationPlugin({
274
+ sections: options.sections,
275
+ sectionGeometries,
276
+ pageGapPx: DEFAULT_PAGE_GAP_PX,
277
+ onLayoutChange: (layout) => {
278
+ pageChrome.setLayout(layout);
279
+ if (footnoteRegistry) {
280
+ const perPage = buildPerPageFootnotes(
281
+ footnoteRegistry,
282
+ editorView.state.doc,
283
+ layout,
284
+ );
285
+ pageChrome.setFootnotes(perPage);
286
+ }
287
+ options.onPageCountChange?.(layout.totalPages);
288
+ },
289
+ });
290
+
291
+ const allPlugins = [
292
+ ...basePlugins,
293
+ ...(options.imageRegistry ? [imageRegistryPlugin(options.imageRegistry)] : []),
294
+ ...(isEditable ? [placeholderPlugin("Type / for commands…")] : []),
295
+ paginationPluginInstance,
296
+ ];
297
+
298
+ const state = EditorState.create({
299
+ schema: docxSchema,
300
+ doc: pmDoc,
301
+ plugins: allPlugins,
302
+ });
303
+
304
+ const relationships = options.relationships ?? new Map();
305
+ const media = options.mediaResolver ?? NULL_MEDIA_RESOLVER;
306
+ const imageView = buildImageInlineView(media);
307
+
308
+ // Editor content overlays the page chrome stack. Padding-top/bottom
309
+ // matches the first page's header/footer bands so the first paragraph
310
+ // lands inside page 1's body slot. Left/right padding matches the page
311
+ // margins so content sits in the body slot horizontally.
312
+ const editorMount = document.createElement("div");
313
+ editorMount.className = "docx-editor-mount";
314
+ editorMount.style.position = "absolute";
315
+ editorMount.style.top = "0";
316
+ editorMount.style.left = "0";
317
+ editorMount.style.right = "0";
318
+ // Padding mirrors page 1's geometry — first-section header band on top,
319
+ // first-section side margins horizontally. Multi-section documents
320
+ // whose later sections use different left/right margins will misalign
321
+ // their body content against the chrome layer's body slot. Tracked as
322
+ // a Phase 5 known gap in docs/docx.md.
323
+ editorMount.style.paddingTop = `${firstGeometry.headerBand}px`;
324
+ editorMount.style.paddingLeft = `${(firstGeometry.pageWidth - bodyWidth(firstSection)) / 2}px`;
325
+ editorMount.style.paddingRight = `${(firstGeometry.pageWidth - bodyWidth(firstSection)) / 2}px`;
326
+ editorMount.style.pointerEvents = "none";
327
+
328
+ // Multi-column body slots. When every section in the document declares
329
+ // the same column count > 1, apply CSS multi-column to the editor
330
+ // content. The reader still sees content flowing top-to-bottom; the
331
+ // browser splits it into N balanced columns. For mixed-section column
332
+ // counts (e.g., section 1 single-column, section 2 two-column), this
333
+ // is deferred — multi-section column awareness requires per-section
334
+ // wrappers, which is a Phase 5 design itself.
335
+ const columnCounts = new Set(
336
+ options.sections.map((s) => s.properties.columns.count),
337
+ );
338
+ if (columnCounts.size === 1) {
339
+ const count = options.sections[0]?.properties.columns.count ?? 1;
340
+ if (count > 1) {
341
+ editorMount.style.columnCount = String(count);
342
+ const spaceDxa =
343
+ options.sections[0]?.properties.columns.space ?? 720;
344
+ editorMount.style.columnGap = `${dxaToPx(spaceDxa)}px`;
345
+ editorMount.style.columnFill = "auto";
346
+ }
347
+ } else if (columnCounts.size > 1) {
348
+ // eslint-disable-next-line no-console
349
+ console.warn(
350
+ "[docx-editor] Document mixes section column counts; multi-column rendering disabled. Sections must share a column count for the Phase 5 multi-column path to apply.",
351
+ );
352
+ }
353
+
354
+ stackRoot.appendChild(editorMount);
355
+
356
+ const onChange = options.onChange;
357
+ const clipboardHooks = isEditable
358
+ ? buildClipboardHooks({
359
+ imageRegistry: options.imageRegistry,
360
+ mediaResolver: media,
361
+ })
362
+ : null;
363
+ const editorView = new EditorView(editorMount, {
364
+ state,
365
+ editable: () => isEditable && !subViewFocused,
366
+ dispatchTransaction(transaction) {
367
+ const newState = editorView.state.apply(transaction);
368
+ editorView.updateState(newState);
369
+ if (transaction.docChanged && onChange) {
370
+ onChange(newState.doc);
371
+ }
372
+ },
373
+ transformPastedHTML: clipboardHooks?.transformPastedHTML,
374
+ handlePaste: clipboardHooks?.handlePaste,
375
+ handleDrop: clipboardHooks?.handleDrop,
376
+ nodeViews: {
377
+ paragraph: buildParagraphView(options.themeTable ?? null),
378
+ section_break: (node: PMNode): NodeView => sectionBreakView(node),
379
+ opaque_block: (node: PMNode): NodeView => opaqueBlockView(node),
380
+ opaque_inline: (): NodeView => opaqueInlineView(),
381
+ image_inline: (node: PMNode, v, getPos): NodeView => imageView(node, v, getPos),
382
+ field_inline: (node: PMNode, v, getPos): NodeView =>
383
+ fieldInlineView(node, v, getPos as () => number | undefined),
384
+ },
385
+ markViews: {
386
+ link: buildLinkMarkView(relationships),
387
+ },
388
+ attributes: {
389
+ class: "docx-editor-content",
390
+ "data-docx-editor": mode,
391
+ spellcheck: isEditable ? "true" : "false",
392
+ },
393
+ });
394
+
395
+ // Re-enable pointer events on the editor's DOM (we disabled them on the
396
+ // mount wrapper so that clicks in gap regions fall through to the chrome
397
+ // layer if needed, but the content area itself must remain interactive).
398
+ (editorView.dom as HTMLElement).style.pointerEvents = "auto";
399
+
400
+
401
+ const setZoom = (zoom: number) => {
402
+ canvasInner.style.transform = `scale(${zoom})`;
403
+ };
404
+
405
+ const pagination: PaginationHandle = {
406
+ getPageCount: () => {
407
+ const layout = getPaginationState(editorView.state)?.layout;
408
+ return layout?.totalPages ?? 1;
409
+ },
410
+ getLayout: () => getPaginationState(editorView.state)?.layout ?? null,
411
+ };
412
+
413
+ const commitHeaderFooterEdits = (
414
+ parts: ReadonlyMap<string, Uint8Array>,
415
+ ): Map<string, Uint8Array> => {
416
+ const out = new Map(parts);
417
+ if (editableHeaderFooters.size === 0) return out;
418
+ const encoder = new TextEncoder();
419
+ for (const { view: hfView, kind, partPath } of editableHeaderFooters.values()) {
420
+ const xml = pmDocToHeaderFooterXml(hfView.state.doc, kind);
421
+ out.set(partPath, encoder.encode(xml));
422
+ }
423
+ return out;
424
+ };
425
+
426
+ return {
427
+ editorView,
428
+ canvasElement,
429
+ canvasInner,
430
+ styleElement,
431
+ pagination,
432
+ getDocument: () => editorView.state.doc,
433
+ setZoom,
434
+ commitHeaderFooterEdits,
435
+ destroy: () => {
436
+ for (const { view: hfView } of editableHeaderFooters.values()) {
437
+ hfView.destroy();
438
+ }
439
+ pageChrome.destroy();
440
+ editorView.destroy();
441
+ ruler.destroy();
442
+ styleElement.remove();
443
+ canvasElement.remove();
444
+ },
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Body width = pageWidth minus left/right margins. Renderer-local helper
450
+ * because the section properties carry margins in dxa.
451
+ */
452
+ function bodyWidth(props: import("../model/sections").SectionProperties): number {
453
+ return (
454
+ dxaToPx(props.pageSize.width) -
455
+ dxaToPx(props.margins.left) -
456
+ dxaToPx(props.margins.right)
457
+ );
458
+ }
459
+
460
+ /**
461
+ * Group the document's footnote references by the page they appear on,
462
+ * then build per-page footnote DOM fragments. The chrome manager mounts
463
+ * the fragment above the section footer band of each page.
464
+ *
465
+ * A footnote is mounted on the page that contains the first reference to
466
+ * it. Subsequent references on later pages do not re-mount the body — they
467
+ * still display the small superscript marker via the footnote_ref NodeView,
468
+ * pointing at the canonical body on the earlier page. This matches Word's
469
+ * default "Reference Mark" behavior for repeated references.
470
+ */
471
+ function buildPerPageFootnotes(
472
+ registry: import("../parse/footnotes").FootnoteRegistry,
473
+ doc: import("prosemirror-model").Node,
474
+ layout: LayoutResult,
475
+ ): ReadonlyMap<number, HTMLElement> {
476
+ // Walk every footnote_ref in the doc and record (id, blockIndex).
477
+ type Ref = { id: string; blockIndex: number; number: number };
478
+ const refs: Ref[] = [];
479
+ const seen = new Set<string>();
480
+ let blockIndex = -1;
481
+ doc.descendants((node, _pos, parent) => {
482
+ if (parent === null) return true;
483
+ if (parent === doc) {
484
+ // The `descendants` callback fires for the top-level children too —
485
+ // for those, advance the block index.
486
+ blockIndex += 1;
487
+ }
488
+ if (node.type.name === "footnote_ref") {
489
+ const id = node.attrs.footnoteId as string;
490
+ if (!id || seen.has(id)) return false;
491
+ seen.add(id);
492
+ refs.push({
493
+ id,
494
+ blockIndex,
495
+ number: node.attrs.number as number,
496
+ });
497
+ return false;
498
+ }
499
+ return true;
500
+ });
501
+ if (refs.length === 0) return new Map();
502
+
503
+ // Map blockIndex → pageNumber via the layout.
504
+ const pageOfBlock = (idx: number): number => {
505
+ for (const page of layout.pages) {
506
+ if (idx >= page.firstBlockIndex && idx <= page.lastBlockIndex) {
507
+ return page.pageNumber;
508
+ }
509
+ }
510
+ return 1;
511
+ };
512
+
513
+ // Bucket refs by page.
514
+ const byPage = new Map<number, Ref[]>();
515
+ for (const ref of refs) {
516
+ const page = pageOfBlock(ref.blockIndex);
517
+ const bucket = byPage.get(page) ?? [];
518
+ bucket.push(ref);
519
+ byPage.set(page, bucket);
520
+ }
521
+
522
+ // Render each page's footnote DOM.
523
+ const out = new Map<number, HTMLElement>();
524
+ for (const [pageNumber, pageRefs] of byPage) {
525
+ const wrapper = document.createElement("aside");
526
+ wrapper.className = "docx-footnotes";
527
+ const ol = document.createElement("ol");
528
+ ol.className = "docx-footnotes__list";
529
+ for (const ref of pageRefs) {
530
+ const entry = registry.byId.get(ref.id);
531
+ if (!entry) continue;
532
+ const li = document.createElement("li");
533
+ li.className = "docx-footnote";
534
+ li.setAttribute("data-footnote-id", entry.id);
535
+ li.setAttribute("value", String(ref.number));
536
+ // Reuse the existing block-rendering helpers via the registry-only
537
+ // `renderFootnotesSection` builds a complete OL; for per-page we
538
+ // need the individual entry. The helper isn't exported, so this
539
+ // duplication is intentional and small.
540
+ for (const block of entry.blocks) {
541
+ if (block.kind === "paragraph") {
542
+ const xml = paragraphBlockForFootnote(block);
543
+ li.appendChild(
544
+ renderFootnoteParagraph(xml),
545
+ );
546
+ }
547
+ }
548
+ ol.appendChild(li);
549
+ }
550
+ wrapper.appendChild(ol);
551
+ out.set(pageNumber, wrapper);
552
+ }
553
+ return out;
554
+ }
555
+
556
+ // TODO(phase-5): Unify with `footnotes_view.ts`. The OOXML→XmlElement
557
+ // conversion is duplicated here so per-page rendering can produce a
558
+ // single <li>, while `footnotes_view.ts` produces the whole <aside><ol>.
559
+ // When editable footnotes land, this duplication should fold into a
560
+ // shared helper exported from `footnotes_view.ts`.
561
+ function paragraphBlockForFootnote(
562
+ block: Extract<import("../model/types").Block, { kind: "paragraph" }>,
563
+ ): import("@lotics/ooxml/xml").XmlElement {
564
+ const children: import("@lotics/ooxml/xml").XmlElement[] = [];
565
+ if (block.properties !== null) {
566
+ children.push({ "w:pPr": [...block.properties] });
567
+ }
568
+ for (const inline of block.content) {
569
+ if (inline.kind === "run") {
570
+ const runChildren: import("@lotics/ooxml/xml").XmlElement[] = [];
571
+ if (inline.properties !== null) {
572
+ runChildren.push({ "w:rPr": [...inline.properties] });
573
+ }
574
+ for (const c of inline.content) {
575
+ if (c.kind === "text") {
576
+ const t: import("@lotics/ooxml/xml").XmlElement = {
577
+ "w:t": c.value === "" ? [] : [{ "#text": c.value }],
578
+ };
579
+ if (c.preserveSpace) t[":@"] = { "@_xml:space": "preserve" };
580
+ runChildren.push(t);
581
+ } else {
582
+ runChildren.push(c.xml);
583
+ }
584
+ }
585
+ children.push({ "w:r": runChildren });
586
+ } else {
587
+ children.push(inline.xml);
588
+ }
589
+ }
590
+ return { "w:p": children };
591
+ }
592
+
593
+ function renderFootnoteParagraph(
594
+ xml: import("@lotics/ooxml/xml").XmlElement,
595
+ ): HTMLElement {
596
+ // Delegate to the table_dom paragraph renderer so footnote text is
597
+ // styled identically to body paragraphs.
598
+ return renderParagraph(xml);
599
+ }
600
+
601
+ export function createEditableView(
602
+ host: HTMLElement,
603
+ pmDoc: PMNode,
604
+ options: Omit<DocxViewOptions, "mode">,
605
+ ): ReadOnlyView {
606
+ return createReadOnlyView(host, pmDoc, { ...options, mode: "editable" });
607
+ }