@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,296 @@
1
+ import type { Node as PMNode } from "prosemirror-model";
2
+ import type { EditorView, NodeView } from "prosemirror-view";
3
+ import { emuToPx, pxToEmu } from "./units";
4
+ import type { MediaResolver } from "./media_resolver";
5
+ import { isTableXml, renderTable } from "./table_dom";
6
+ import { paginationPluginKey } from "../pm/pagination_plugin";
7
+ import type { LayoutResult } from "../layout/types";
8
+
9
+ export function sectionBreakView(node: PMNode): NodeView {
10
+ const dom = document.createElement("div");
11
+ dom.className = "docx-section-break";
12
+ dom.setAttribute("data-section-break", "true");
13
+ dom.setAttribute("aria-hidden", "true");
14
+ if (node.attrs.isFinal === true) {
15
+ dom.setAttribute("data-final", "true");
16
+ }
17
+ return { dom };
18
+ }
19
+
20
+ export function opaqueBlockView(node: PMNode): NodeView {
21
+ const dom = document.createElement("div");
22
+ dom.className = "docx-opaque-block";
23
+ dom.setAttribute("data-opaque-block", "true");
24
+
25
+ const xml = node.attrs.xml;
26
+ if (isTableXml(xml)) {
27
+ dom.appendChild(renderTable(xml));
28
+ } else {
29
+ const placeholder = document.createElement("div");
30
+ placeholder.className = "docx-opaque-block__placeholder";
31
+ placeholder.textContent = "[unsupported content]";
32
+ dom.appendChild(placeholder);
33
+ }
34
+ return { dom };
35
+ }
36
+
37
+ export function opaqueInlineView(): NodeView {
38
+ const dom = document.createElement("span");
39
+ dom.className = "docx-opaque-inline";
40
+ dom.setAttribute("data-opaque-inline", "true");
41
+ return { dom };
42
+ }
43
+
44
+ function applyAnchorStyle(
45
+ wrapper: HTMLElement,
46
+ wrap: string | null,
47
+ floatSide: string | null,
48
+ behindDoc: boolean,
49
+ offsetXEmu: number | null,
50
+ offsetYEmu: number | null,
51
+ ): void {
52
+ // behindDoc: image renders behind text. The wrapper stays in flow when
53
+ // wrap is "none" but its z-index drops below the editor content.
54
+ if (behindDoc) {
55
+ wrapper.style.zIndex = "-1";
56
+ }
57
+
58
+ if (!wrap) return;
59
+ if (wrap === "topAndBottom") {
60
+ wrapper.style.display = "block";
61
+ wrapper.style.clear = "both";
62
+ return;
63
+ }
64
+ if (wrap === "none") {
65
+ // wrap:none + behindDoc=true = "Behind text" mode (image positioned
66
+ // freely, text flows over it). wrap:none + behindDoc=false = "In front
67
+ // of text" mode.
68
+ wrapper.style.position = "absolute";
69
+ if (offsetXEmu !== null) wrapper.style.left = `${emuToPx(offsetXEmu)}px`;
70
+ if (offsetYEmu !== null) wrapper.style.top = `${emuToPx(offsetYEmu)}px`;
71
+ if (!behindDoc) wrapper.style.zIndex = "2";
72
+ return;
73
+ }
74
+ if (floatSide === "right") {
75
+ wrapper.style.float = "right";
76
+ wrapper.style.marginLeft = "8px";
77
+ } else {
78
+ wrapper.style.float = "left";
79
+ wrapper.style.marginRight = "8px";
80
+ }
81
+ if (wrap === "tight" || wrap === "through") {
82
+ wrapper.style.shapeOutside = "margin-box";
83
+ }
84
+ }
85
+
86
+ type ResizeCorner = "nw" | "ne" | "sw" | "se";
87
+
88
+ function attachResizeHandles(
89
+ wrapper: HTMLElement,
90
+ img: HTMLElement,
91
+ view: EditorView,
92
+ getPos: () => number | undefined,
93
+ ): void {
94
+ const corners: ResizeCorner[] = ["nw", "ne", "sw", "se"];
95
+ for (const corner of corners) {
96
+ const handle = document.createElement("span");
97
+ handle.className = `docx-image-resize-handle docx-image-resize-handle--${corner}`;
98
+ handle.setAttribute("data-corner", corner);
99
+ handle.setAttribute("contenteditable", "false");
100
+ wrapper.appendChild(handle);
101
+
102
+ handle.addEventListener("pointerdown", (e) => {
103
+ e.preventDefault();
104
+ e.stopPropagation();
105
+ handle.setPointerCapture(e.pointerId);
106
+ const startX = e.clientX;
107
+ const startY = e.clientY;
108
+ const rect = img.getBoundingClientRect();
109
+ const startW = rect.width;
110
+ const startH = rect.height;
111
+ const ratio = startH / Math.max(startW, 1);
112
+
113
+ const onMove = (m: PointerEvent) => {
114
+ const dxRaw = m.clientX - startX;
115
+ const dyRaw = m.clientY - startY;
116
+ const sx = corner === "ne" || corner === "se" ? 1 : -1;
117
+ const sy = corner === "sw" || corner === "se" ? 1 : -1;
118
+ let nextW = Math.max(20, startW + dxRaw * sx);
119
+ let nextH = Math.max(20, startH + dyRaw * sy);
120
+ if (m.shiftKey) {
121
+ // Constrain aspect using width as driver
122
+ nextH = nextW * ratio;
123
+ }
124
+ img.style.width = `${nextW}px`;
125
+ img.style.height = `${nextH}px`;
126
+ };
127
+ const onUp = (u: PointerEvent) => {
128
+ handle.releasePointerCapture(u.pointerId);
129
+ handle.removeEventListener("pointermove", onMove);
130
+ handle.removeEventListener("pointerup", onUp);
131
+ const finalRect = img.getBoundingClientRect();
132
+ const pos = getPos();
133
+ if (pos === undefined) return;
134
+ const node = view.state.doc.nodeAt(pos);
135
+ if (!node) return;
136
+ const tr = view.state.tr.setNodeMarkup(pos, undefined, {
137
+ ...node.attrs,
138
+ widthEmu: pxToEmu(finalRect.width),
139
+ heightEmu: pxToEmu(finalRect.height),
140
+ });
141
+ view.dispatch(tr);
142
+ };
143
+ handle.addEventListener("pointermove", onMove);
144
+ handle.addEventListener("pointerup", onUp);
145
+ });
146
+ }
147
+ }
148
+
149
+ export function buildImageInlineView(media: MediaResolver) {
150
+ return (node: PMNode, view: EditorView, getPos: () => number | undefined): NodeView => {
151
+ const wrapper = document.createElement("span");
152
+ wrapper.className = "docx-image-inline";
153
+ const relId = node.attrs.relationshipId as string | null;
154
+ if (relId) wrapper.setAttribute("data-rid", relId);
155
+
156
+ const widthEmu = node.attrs.widthEmu as number | null;
157
+ const heightEmu = node.attrs.heightEmu as number | null;
158
+ const alt = (node.attrs.alt as string) ?? "";
159
+ const wrap = node.attrs.wrap as string | null;
160
+ const floatSide = node.attrs.floatSide as string | null;
161
+ const behindDoc = (node.attrs.behindDoc as boolean | null) ?? false;
162
+ const offsetXEmu = node.attrs.offsetXEmu as number | null;
163
+ const offsetYEmu = node.attrs.offsetYEmu as number | null;
164
+
165
+ if (wrap || behindDoc) {
166
+ wrapper.classList.add("docx-image-inline--anchored");
167
+ if (wrap) wrapper.setAttribute("data-wrap", wrap);
168
+ if (floatSide) wrapper.setAttribute("data-float-side", floatSide);
169
+ if (behindDoc) wrapper.setAttribute("data-behind-doc", "true");
170
+ applyAnchorStyle(
171
+ wrapper,
172
+ wrap,
173
+ floatSide,
174
+ behindDoc,
175
+ offsetXEmu,
176
+ offsetYEmu,
177
+ );
178
+ }
179
+
180
+ const url = relId ? media.resolveByRelationshipId(relId) : null;
181
+ if (url) {
182
+ const img = document.createElement("img");
183
+ img.setAttribute("src", url);
184
+ img.setAttribute("alt", alt);
185
+ img.setAttribute("draggable", "false");
186
+ if (widthEmu !== null) img.style.width = `${emuToPx(widthEmu)}px`;
187
+ if (heightEmu !== null) img.style.height = `${emuToPx(heightEmu)}px`;
188
+ wrapper.appendChild(img);
189
+ if (view.editable) attachResizeHandles(wrapper, img, view, getPos);
190
+ } else {
191
+ const placeholder = document.createElement("span");
192
+ placeholder.className = "docx-image-inline__placeholder";
193
+ placeholder.textContent = alt ? `[image: ${alt}]` : "[image]";
194
+ wrapper.appendChild(placeholder);
195
+ }
196
+ return { dom: wrapper };
197
+ };
198
+ }
199
+
200
+ export function imageInlineView(
201
+ node: PMNode,
202
+ view: EditorView,
203
+ getPos: () => number | undefined,
204
+ ): NodeView {
205
+ return buildImageInlineView({
206
+ resolveByRelationshipId: () => null,
207
+ destroy: () => {},
208
+ })(node, view, getPos);
209
+ }
210
+
211
+ export function fieldInlineView(
212
+ node: PMNode,
213
+ view: import("prosemirror-view").EditorView | undefined,
214
+ getPos: (() => number | undefined) | undefined,
215
+ ): NodeView {
216
+ const dom = document.createElement("span");
217
+ dom.className = "docx-field-inline";
218
+ dom.setAttribute("data-field", node.attrs.instruction as string);
219
+
220
+ const render = (currentNode: PMNode) => {
221
+ const instruction = (currentNode.attrs.instruction as string) || "";
222
+ const resolved = resolveFieldText(instruction, view, getPos);
223
+ dom.textContent =
224
+ resolved !== null
225
+ ? resolved
226
+ : ((currentNode.attrs.cachedResult as string) || "");
227
+ };
228
+
229
+ render(node);
230
+
231
+ return {
232
+ dom,
233
+ update(nextNode) {
234
+ if (nextNode.type.name !== "field_inline") return false;
235
+ render(nextNode);
236
+ return true;
237
+ },
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Resolve PAGE / NUMPAGES field instructions against the pagination
243
+ * plugin state. Returns null for instructions the renderer doesn't
244
+ * recognize, so the caller falls back to the cached OOXML result.
245
+ *
246
+ * Word fields use a SPACE-separated instruction string. We tolerate
247
+ * whitespace and switches by checking the first significant token.
248
+ */
249
+ function resolveFieldText(
250
+ instruction: string,
251
+ view: import("prosemirror-view").EditorView | undefined,
252
+ getPos: (() => number | undefined) | undefined,
253
+ ): string | null {
254
+ if (!view) return null;
255
+ const token = instruction.trim().split(/\s+/)[0]?.toUpperCase() ?? "";
256
+ if (token !== "PAGE" && token !== "NUMPAGES") return null;
257
+ const layout = paginationPluginKey.getState(view.state)?.layout;
258
+ if (!layout) return null;
259
+ if (token === "NUMPAGES") return String(layout.totalPages);
260
+ const pos = getPos?.();
261
+ if (pos === undefined) return null;
262
+ const pageNumber = pageNumberForPos(layout, view.state.doc, pos);
263
+ return String(pageNumber);
264
+ }
265
+
266
+ /**
267
+ * Map a PM doc position to the page number that contains it. Uses the
268
+ * cumulative block sizes to determine which top-level block holds the
269
+ * position, then looks the block up in the layout result.
270
+ */
271
+ function pageNumberForPos(
272
+ layout: LayoutResult,
273
+ doc: PMNode,
274
+ pos: number,
275
+ ): number {
276
+ let acc = 0;
277
+ let blockIndex = -1;
278
+ for (let i = 0; i < doc.childCount; i++) {
279
+ const size = doc.child(i).nodeSize;
280
+ if (pos >= acc && pos < acc + size) {
281
+ blockIndex = i;
282
+ break;
283
+ }
284
+ acc += size;
285
+ }
286
+ if (blockIndex === -1) return 1;
287
+ for (const page of layout.pages) {
288
+ if (
289
+ blockIndex >= page.firstBlockIndex &&
290
+ blockIndex <= page.lastBlockIndex
291
+ ) {
292
+ return page.pageNumber;
293
+ }
294
+ }
295
+ return 1;
296
+ }
@@ -0,0 +1,149 @@
1
+ import type {
2
+ AbstractNum,
3
+ NumberFormat,
4
+ NumberingLevel,
5
+ NumberingTable,
6
+ } from "../model/numbering_table";
7
+
8
+ export type NumberingState = Map<number, Map<number, number>>;
9
+
10
+ function intToLowerLetter(n: number): string {
11
+ if (n <= 0) return "";
12
+ let value = n;
13
+ let out = "";
14
+ while (value > 0) {
15
+ const rem = (value - 1) % 26;
16
+ out = String.fromCharCode(0x61 + rem) + out;
17
+ value = Math.floor((value - 1) / 26);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function intToRoman(n: number): string {
23
+ if (n <= 0) return "";
24
+ const map: Array<[number, string]> = [
25
+ [1000, "m"],
26
+ [900, "cm"],
27
+ [500, "d"],
28
+ [400, "cd"],
29
+ [100, "c"],
30
+ [90, "xc"],
31
+ [50, "l"],
32
+ [40, "xl"],
33
+ [10, "x"],
34
+ [9, "ix"],
35
+ [5, "v"],
36
+ [4, "iv"],
37
+ [1, "i"],
38
+ ];
39
+ let value = n;
40
+ let out = "";
41
+ for (const [v, sym] of map) {
42
+ while (value >= v) {
43
+ out += sym;
44
+ value -= v;
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function formatLevelValue(numFmt: NumberFormat, value: number): string {
51
+ switch (numFmt) {
52
+ case "decimal":
53
+ return String(value);
54
+ case "decimalZero":
55
+ return value < 10 ? `0${value}` : String(value);
56
+ case "lowerLetter":
57
+ return intToLowerLetter(value);
58
+ case "upperLetter":
59
+ return intToLowerLetter(value).toUpperCase();
60
+ case "lowerRoman":
61
+ return intToRoman(value);
62
+ case "upperRoman":
63
+ return intToRoman(value).toUpperCase();
64
+ case "bullet":
65
+ case "none":
66
+ return "";
67
+ }
68
+ }
69
+
70
+ function applyTemplate(
71
+ lvlText: string,
72
+ counters: ReadonlyMap<number, number>,
73
+ abstractNum: AbstractNum,
74
+ ): string {
75
+ return lvlText.replace(/%(\d+)/g, (_, idxStr: string) => {
76
+ const idx = Number.parseInt(idxStr, 10) - 1;
77
+ const value = counters.get(idx) ?? abstractNum.levels.get(idx)?.start ?? 1;
78
+ const fmt = abstractNum.levels.get(idx)?.numFmt ?? "decimal";
79
+ return formatLevelValue(fmt, value);
80
+ });
81
+ }
82
+
83
+ function getLevel(
84
+ table: NumberingTable,
85
+ numId: number,
86
+ ilvl: number,
87
+ ): { level: NumberingLevel; abstractNum: AbstractNum } | null {
88
+ const num = table.nums.get(numId);
89
+ if (!num) return null;
90
+ const abstractNum = table.abstractNums.get(num.abstractNumId);
91
+ if (!abstractNum) return null;
92
+
93
+ const override = num.overrides.get(ilvl);
94
+ if (override?.level) {
95
+ return { level: override.level, abstractNum };
96
+ }
97
+
98
+ const level = abstractNum.levels.get(ilvl);
99
+ if (!level) return null;
100
+ return { level, abstractNum };
101
+ }
102
+
103
+ export function createNumberingState(): NumberingState {
104
+ return new Map();
105
+ }
106
+
107
+ export function computeLabel(
108
+ state: NumberingState,
109
+ table: NumberingTable,
110
+ numId: number,
111
+ ilvl: number,
112
+ ): string | null {
113
+ const resolved = getLevel(table, numId, ilvl);
114
+ if (!resolved) return null;
115
+ const { level, abstractNum } = resolved;
116
+
117
+ let counters = state.get(numId);
118
+ if (!counters) {
119
+ counters = new Map();
120
+ state.set(numId, counters);
121
+ }
122
+
123
+ const num = table.nums.get(numId);
124
+ const override = num?.overrides.get(ilvl);
125
+
126
+ let current = counters.get(ilvl);
127
+ if (current === undefined) {
128
+ current = override?.startOverride ?? level.start;
129
+ } else {
130
+ current += 1;
131
+ }
132
+ counters.set(ilvl, current);
133
+
134
+ for (const [otherIlvl, lvl] of abstractNum.levels) {
135
+ if (otherIlvl <= ilvl) continue;
136
+ const restart = lvl.lvlRestart ?? otherIlvl;
137
+ if (restart >= ilvl + 1) {
138
+ counters.delete(otherIlvl);
139
+ }
140
+ }
141
+
142
+ if (level.numFmt === "bullet") {
143
+ return level.lvlText;
144
+ }
145
+ if (level.numFmt === "none") {
146
+ return "";
147
+ }
148
+ return applyTemplate(level.lvlText, counters, abstractNum);
149
+ }
@@ -0,0 +1,262 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import {
4
+ DEFAULT_SECTION_PROPERTIES,
5
+ type Section,
6
+ } from "../model/sections";
7
+ import type { HeaderFooterRegistry } from "../parse/header_footer";
8
+ import type { LayoutResult, SectionGeometry } from "../layout/types";
9
+ import { createPageChromeManager } from "./page_chrome";
10
+
11
+ const baseSection: Section = {
12
+ properties: { ...DEFAULT_SECTION_PROPERTIES },
13
+ blockStartIndex: 0,
14
+ blockEndIndex: 0,
15
+ };
16
+
17
+ const baseGeometry: SectionGeometry = {
18
+ sectionIndex: 0,
19
+ blockStartIndex: 0,
20
+ blockEndIndex: 0,
21
+ pageHeight: 1056,
22
+ pageWidth: 816,
23
+ pageContentHeight: 900,
24
+ headerBand: 78,
25
+ footerBand: 78,
26
+ titlePage: false,
27
+ hasEvenHeader: false,
28
+ };
29
+
30
+ function layout(pageCount: number): LayoutResult {
31
+ return {
32
+ pages: Array.from({ length: pageCount }, (_, i) => ({
33
+ pageNumber: i + 1,
34
+ sectionIndex: 0,
35
+ firstBlockIndex: i,
36
+ lastBlockIndex: i,
37
+ headerKind: "default" as const,
38
+ isFirstInSection: i === 0,
39
+ startsWithContinuation: null,
40
+ endsWithContinuation: null,
41
+ })),
42
+ totalPages: pageCount,
43
+ };
44
+ }
45
+
46
+ let host: HTMLDivElement;
47
+
48
+ beforeEach(() => {
49
+ document.body.innerHTML = "";
50
+ host = document.createElement("div");
51
+ document.body.appendChild(host);
52
+ });
53
+
54
+ describe("createPageChromeManager", () => {
55
+ it("mounts a layer with N page rectangles", () => {
56
+ const mgr = createPageChromeManager({
57
+ host,
58
+ sections: [baseSection],
59
+ sectionGeometries: [baseGeometry],
60
+ headerFooterRegistry: null,
61
+ });
62
+
63
+ mgr.setLayout(layout(3));
64
+
65
+ const layer = host.querySelector(".docx-pages-layer");
66
+ expect(layer).toBeTruthy();
67
+ expect(layer?.querySelectorAll(".docx-page")).toHaveLength(3);
68
+ });
69
+
70
+ it("positions pages stacked with a fixed gap", () => {
71
+ const mgr = createPageChromeManager({
72
+ host,
73
+ sections: [baseSection],
74
+ sectionGeometries: [baseGeometry],
75
+ headerFooterRegistry: null,
76
+ pageGapPx: 30,
77
+ });
78
+
79
+ mgr.setLayout(layout(3));
80
+
81
+ const pages = Array.from(host.querySelectorAll<HTMLElement>(".docx-page"));
82
+ expect(pages[0].style.top).toBe("0px");
83
+ // Page 2: 1056 + 30 = 1086
84
+ expect(pages[1].style.top).toBe("1086px");
85
+ // Page 3: 2 * (1056 + 30) = 2172
86
+ expect(pages[2].style.top).toBe("2172px");
87
+
88
+ // Layer total height = 3 pages + 2 gaps = 3*1056 + 2*30 = 3228
89
+ expect((host.querySelector(".docx-pages-layer") as HTMLElement).style.height).toBe(
90
+ "3228px",
91
+ );
92
+ });
93
+
94
+ it("adds new pages when layout grows", () => {
95
+ const mgr = createPageChromeManager({
96
+ host,
97
+ sections: [baseSection],
98
+ sectionGeometries: [baseGeometry],
99
+ headerFooterRegistry: null,
100
+ });
101
+
102
+ mgr.setLayout(layout(2));
103
+ expect(host.querySelectorAll(".docx-page")).toHaveLength(2);
104
+
105
+ mgr.setLayout(layout(5));
106
+ expect(host.querySelectorAll(".docx-page")).toHaveLength(5);
107
+ });
108
+
109
+ it("removes pages when layout shrinks", () => {
110
+ const mgr = createPageChromeManager({
111
+ host,
112
+ sections: [baseSection],
113
+ sectionGeometries: [baseGeometry],
114
+ headerFooterRegistry: null,
115
+ });
116
+
117
+ mgr.setLayout(layout(5));
118
+ mgr.setLayout(layout(2));
119
+ expect(host.querySelectorAll(".docx-page")).toHaveLength(2);
120
+ });
121
+
122
+ it("retains existing page DOM when same page number persists across layouts", () => {
123
+ const mgr = createPageChromeManager({
124
+ host,
125
+ sections: [baseSection],
126
+ sectionGeometries: [baseGeometry],
127
+ headerFooterRegistry: null,
128
+ });
129
+
130
+ mgr.setLayout(layout(2));
131
+ const page1Before = host.querySelector('[data-docx-page="1"]');
132
+ mgr.setLayout(layout(3));
133
+ const page1After = host.querySelector('[data-docx-page="1"]');
134
+ // Same DOM node — manager diffed and reused.
135
+ expect(page1Before).toBe(page1After);
136
+ });
137
+
138
+ it("destroys cleanly", () => {
139
+ const mgr = createPageChromeManager({
140
+ host,
141
+ sections: [baseSection],
142
+ sectionGeometries: [baseGeometry],
143
+ headerFooterRegistry: null,
144
+ });
145
+ mgr.setLayout(layout(3));
146
+ mgr.destroy();
147
+ expect(host.querySelectorAll(".docx-pages-layer")).toHaveLength(0);
148
+ });
149
+
150
+ it("reports total height and gap heights via getter", () => {
151
+ const mgr = createPageChromeManager({
152
+ host,
153
+ sections: [baseSection],
154
+ sectionGeometries: [baseGeometry],
155
+ headerFooterRegistry: null,
156
+ pageGapPx: 20,
157
+ });
158
+ mgr.setLayout(layout(3));
159
+ // 3 * 1056 + 2 * 20 = 3208
160
+ expect(mgr.getTotalHeight()).toBe(3208);
161
+ expect(mgr.getGapHeights()).toEqual([20, 20]);
162
+ });
163
+ });
164
+
165
+ describe("createPageChromeManager — header/footer factory", () => {
166
+ it("falls back to read-only DOM when no factory is provided", () => {
167
+ const registry: HeaderFooterRegistry = {
168
+ byRelationshipId: new Map([
169
+ [
170
+ "rId1",
171
+ {
172
+ blocks: [
173
+ {
174
+ kind: "paragraph",
175
+ properties: null,
176
+ content: [
177
+ {
178
+ kind: "run",
179
+ properties: null,
180
+ content: [
181
+ { kind: "text", value: "Header text", preserveSpace: false },
182
+ ],
183
+ },
184
+ ],
185
+ },
186
+ ],
187
+ },
188
+ ],
189
+ ]),
190
+ };
191
+ const sectionWithHeader: Section = {
192
+ properties: {
193
+ ...DEFAULT_SECTION_PROPERTIES,
194
+ headerRefs: [{ type: "default", relationshipId: "rId1" }],
195
+ },
196
+ blockStartIndex: 0,
197
+ blockEndIndex: 0,
198
+ };
199
+
200
+ const mgr = createPageChromeManager({
201
+ host,
202
+ sections: [sectionWithHeader],
203
+ sectionGeometries: [baseGeometry],
204
+ headerFooterRegistry: registry,
205
+ });
206
+
207
+ mgr.setLayout(layout(2));
208
+
209
+ const headers = host.querySelectorAll('[data-docx-header="true"]');
210
+ expect(headers).toHaveLength(2);
211
+ expect(headers[0].textContent).toContain("Header text");
212
+ expect(headers[1].textContent).toContain("Header text");
213
+ });
214
+
215
+ it("uses the factory override when supplied; receives page metadata", () => {
216
+ const registry: HeaderFooterRegistry = {
217
+ byRelationshipId: new Map([
218
+ [
219
+ "rId1",
220
+ {
221
+ blocks: [
222
+ {
223
+ kind: "paragraph",
224
+ properties: null,
225
+ content: [],
226
+ },
227
+ ],
228
+ },
229
+ ],
230
+ ]),
231
+ };
232
+ const sectionWithHeader: Section = {
233
+ properties: {
234
+ ...DEFAULT_SECTION_PROPERTIES,
235
+ headerRefs: [{ type: "default", relationshipId: "rId1" }],
236
+ },
237
+ blockStartIndex: 0,
238
+ blockEndIndex: 0,
239
+ };
240
+
241
+ const factoryCalls: number[] = [];
242
+ const mgr = createPageChromeManager({
243
+ host,
244
+ sections: [sectionWithHeader],
245
+ sectionGeometries: [baseGeometry],
246
+ headerFooterRegistry: registry,
247
+ renderHeaderFooter: ({ pageNumber, kind }) => {
248
+ factoryCalls.push(pageNumber);
249
+ const el = document.createElement("div");
250
+ el.textContent = `${kind}:${pageNumber}`;
251
+ return el;
252
+ },
253
+ });
254
+
255
+ mgr.setLayout(layout(3));
256
+
257
+ expect(factoryCalls).toEqual([1, 2, 3]);
258
+ expect(host.textContent).toContain("hdr:1");
259
+ expect(host.textContent).toContain("hdr:2");
260
+ expect(host.textContent).toContain("hdr:3");
261
+ });
262
+ });