@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,590 @@
1
+ import { Plugin, PluginKey } from "prosemirror-state";
2
+ import type { EditorState, Transaction } from "prosemirror-state";
3
+ import type { EditorView } from "prosemirror-view";
4
+ import { Decoration, DecorationSet } from "prosemirror-view";
5
+ import type { Node as PMNode } from "prosemirror-model";
6
+
7
+ import type { ParagraphProperties } from "../model/properties";
8
+ import type { Section } from "../model/sections";
9
+ import type {
10
+ LayoutResult,
11
+ MeasuredBlock,
12
+ SectionGeometry,
13
+ } from "../layout/types";
14
+ import { runLayout } from "../layout/engine";
15
+
16
+ export const paginationPluginKey = new PluginKey<PaginationPluginState>(
17
+ "docx-pagination",
18
+ );
19
+
20
+ export type PaginationPluginOptions = {
21
+ /** Sections parsed from the document. The plugin maps each PM block to a section. */
22
+ sections: readonly Section[];
23
+ /** Pre-computed per-section geometries (pageHeight, content area, etc.). */
24
+ sectionGeometries: readonly SectionGeometry[];
25
+ /**
26
+ * Visual gap between page rectangles in CSS pixels. Used by the layout
27
+ * engine to compute each page's `gapHeightBefore` (which the renderer
28
+ * emits as a widget decoration so flow content aligns with chrome).
29
+ */
30
+ pageGapPx: number;
31
+ /** Notified whenever the layout result changes (page count, page boundaries). */
32
+ onLayoutChange?: (layout: LayoutResult) => void;
33
+ };
34
+
35
+ export type PaginationPluginState = {
36
+ layout: LayoutResult | null;
37
+ decorations: DecorationSet;
38
+ };
39
+
40
+ /**
41
+ * Pagination coordinator.
42
+ *
43
+ * On every doc change, schedules a rAF in which it measures each top-level
44
+ * block's offsetHeight via the editor view, runs {@link runLayout}, and
45
+ * emits widget decorations at page boundaries. The {@link PaginationPluginState}
46
+ * exposes the most recent {@link LayoutResult} so other components (status
47
+ * bar, PageChromeManager) can subscribe via {@link paginationPluginKey}.
48
+ *
49
+ * Decorations are visual only — they never mutate the doc model. Page-gap
50
+ * widgets are inserted via {@link Decoration.widget} immediately before the
51
+ * first block of each subsequent page and produce a transparent spacer
52
+ * matching the chrome layer's gap height. This keeps the editor's flowing
53
+ * content aligned with the absolutely-positioned page rectangles behind it.
54
+ *
55
+ * The plugin does not assume a particular DOM layout; it only requires that
56
+ * the editor's DOM contains one direct child per top-level PM block. This
57
+ * matches PM's default rendering for block nodes.
58
+ */
59
+ export function paginationPlugin(
60
+ options: PaginationPluginOptions,
61
+ ): Plugin<PaginationPluginState> {
62
+ return new Plugin<PaginationPluginState>({
63
+ key: paginationPluginKey,
64
+ state: {
65
+ init: () => ({ layout: null, decorations: DecorationSet.empty }),
66
+ apply: (tr, value) => {
67
+ const meta = tr.getMeta(paginationPluginKey) as
68
+ | { layout: LayoutResult; decorations: DecorationSet }
69
+ | undefined;
70
+ if (meta) return meta;
71
+ if (!tr.docChanged) return value;
72
+ // Map existing decorations through the transaction's mapping so
73
+ // they survive position-preserving changes. They'll be replaced
74
+ // on the next layout pass.
75
+ return {
76
+ layout: value.layout,
77
+ decorations: value.decorations.map(tr.mapping, tr.doc),
78
+ };
79
+ },
80
+ },
81
+ props: {
82
+ decorations(state) {
83
+ return paginationPluginKey.getState(state)?.decorations ?? null;
84
+ },
85
+ },
86
+ view(editorView) {
87
+ let rafHandle: number | null = null;
88
+ let disposed = false;
89
+
90
+ const recompute = () => {
91
+ rafHandle = null;
92
+ if (disposed) return;
93
+ const state = editorView.state;
94
+ const blocks = measureBlocks(editorView, state, options);
95
+ const layout = runLayout(blocks, options.sectionGeometries, {
96
+ minLinesBeforeBreak: 2,
97
+ minLinesAfterBreak: 2,
98
+ visualPageGapPx: options.pageGapPx,
99
+ });
100
+ const decorations = buildDecorations(state.doc, layout, editorView);
101
+ const prev = paginationPluginKey.getState(state);
102
+ if (prev && layoutsEqual(prev.layout, layout)) return;
103
+ editorView.dispatch(
104
+ state.tr.setMeta(paginationPluginKey, { layout, decorations }),
105
+ );
106
+ options.onLayoutChange?.(layout);
107
+ };
108
+
109
+ const schedule = () => {
110
+ if (disposed) return;
111
+ if (rafHandle !== null) return;
112
+ rafHandle = window.requestAnimationFrame(recompute);
113
+ };
114
+
115
+ // Initial pass — deferred to a microtask so the EditorView
116
+ // constructor finishes before the plugin dispatches the first
117
+ // pagination transaction. PM forbids `view.dispatch` from inside
118
+ // a plugin's `view()` callback because the EditorView is not yet
119
+ // fully constructed. queueMicrotask gives us the earliest legal
120
+ // dispatch slot (before paint) so users still see chrome on first
121
+ // paint instead of after an rAF tick.
122
+ queueMicrotask(() => {
123
+ if (!disposed) recompute();
124
+ });
125
+
126
+ // ResizeObserver catches font-load and container-resize cases that
127
+ // PM transactions don't trigger.
128
+ const ro =
129
+ typeof ResizeObserver !== "undefined"
130
+ ? new ResizeObserver(schedule)
131
+ : null;
132
+ ro?.observe(editorView.dom);
133
+
134
+ return {
135
+ update: (_view, prevState) => {
136
+ if (editorView.state.doc !== prevState.doc) {
137
+ schedule();
138
+ }
139
+ },
140
+ destroy: () => {
141
+ disposed = true;
142
+ if (rafHandle !== null) cancelAnimationFrame(rafHandle);
143
+ ro?.disconnect();
144
+ },
145
+ };
146
+ },
147
+ });
148
+ }
149
+
150
+ // =========================================================================
151
+ // Measurement
152
+ // =========================================================================
153
+
154
+ /**
155
+ * Walk the top-level blocks in the PM doc, look up each block's DOM node,
156
+ * and produce {@link MeasuredBlock}s. Blocks that are paragraphs also
157
+ * receive a line-height array reconstructed from the rendered DOM so the
158
+ * engine can split them at line boundaries.
159
+ *
160
+ * The mapping from block to section uses the section blockStart/blockEnd
161
+ * ranges. Section breaks inside the doc (as `section_break` PM nodes)
162
+ * advance the active section.
163
+ */
164
+ function measureBlocks(
165
+ view: EditorView,
166
+ state: EditorState,
167
+ options: PaginationPluginOptions,
168
+ ): MeasuredBlock[] {
169
+ const blocks: MeasuredBlock[] = [];
170
+ const doc = state.doc;
171
+ let sectionIndex = 0;
172
+ let blockIndex = 0;
173
+ let hasWarnedSectionMismatch = false;
174
+
175
+ doc.forEach((node, _offset, index) => {
176
+ const dom = view.nodeDOM(positionOfChild(doc, index));
177
+ const el = dom instanceof HTMLElement ? dom : null;
178
+ // Use the flow extent (offsetHeight + margin to next sibling) rather
179
+ // than offsetHeight alone. Block-level CSS margins between paragraphs
180
+ // contribute to the actual flow position of subsequent content; if
181
+ // the engine ignores them, heightUsed undercounts and the gap widget
182
+ // ends up too short by ~10 px per inter-paragraph margin on each
183
+ // page, drifting content above the chrome's body slot over time.
184
+ const height = el ? effectiveFlowHeight(el) : 0;
185
+ const lineHeights =
186
+ node.type.name === "paragraph" && el
187
+ ? measureParagraphLineHeights(el)
188
+ : null;
189
+ const rowHeights =
190
+ isTableNode(node) && el ? measureTableRowHeights(el) : null;
191
+
192
+ blocks.push({
193
+ index: blockIndex,
194
+ sectionIndex,
195
+ kind: classifyBlockKind(node),
196
+ height,
197
+ paragraphProperties: node.attrs.properties as ParagraphProperties | null,
198
+ lineHeights,
199
+ rowHeights,
200
+ });
201
+
202
+ if (node.type.name === "section_break") {
203
+ const next = sectionIndex + 1;
204
+ if (next >= options.sections.length) {
205
+ // The doc has more section_break nodes than the parsed
206
+ // sections array describes. Throwing here would crash the
207
+ // editor mid-render; clamping silently produces wrong page
208
+ // geometry. Compromise: clamp and warn so developers see the
209
+ // mismatch in the console without losing the surface.
210
+ if (!hasWarnedSectionMismatch) {
211
+ // eslint-disable-next-line no-console
212
+ console.warn(
213
+ `[docx-editor] section_break at block ${blockIndex} exceeds sections.length (${options.sections.length}). ` +
214
+ "Blocks past the last section will reuse its geometry. Caller should keep `sections` in sync with the document's section_break count.",
215
+ );
216
+ hasWarnedSectionMismatch = true;
217
+ }
218
+ } else {
219
+ sectionIndex = next;
220
+ }
221
+ }
222
+ blockIndex += 1;
223
+ });
224
+
225
+ return blocks;
226
+ }
227
+
228
+ /**
229
+ * Effective flow height of a block: `offsetHeight` plus the whitespace
230
+ * between this element's bottom and the next sibling's top (i.e. the
231
+ * resolved block margin after CSS margin-collapse).
232
+ *
233
+ * Why this matters for pagination: the editor content's flow extent is
234
+ * what determines where the next page's content lands. CSS `<p>` margins
235
+ * are visible in the layout but not in `offsetHeight`. Without
236
+ * accounting for them the engine undercounts heightUsed per page, the
237
+ * gap widget ends up too short, and content drifts above each page's
238
+ * body slot.
239
+ *
240
+ * For the last child of the editor (no next sibling) we return
241
+ * `offsetHeight` — the trailing margin has no in-flow consumer.
242
+ *
243
+ * Page-gap widget siblings are deliberately included in the diff:
244
+ * a paragraph that sits immediately before a gap widget contributes its
245
+ * own margin-bottom (the widget itself has margin: 0, so the diff to its
246
+ * top is exactly the paragraph's margin-bottom).
247
+ */
248
+ function effectiveFlowHeight(el: HTMLElement): number {
249
+ const next = el.nextElementSibling;
250
+ if (!(next instanceof HTMLElement)) return el.offsetHeight;
251
+ const myBottom = el.getBoundingClientRect().bottom;
252
+ const nextTop = next.getBoundingClientRect().top;
253
+ const margin = nextTop - myBottom;
254
+ return el.offsetHeight + Math.max(0, margin);
255
+ }
256
+
257
+ function positionOfChild(doc: PMNode, index: number): number {
258
+ let pos = 0;
259
+ for (let i = 0; i < index; i++) {
260
+ pos += doc.child(i).nodeSize;
261
+ }
262
+ return pos;
263
+ }
264
+
265
+ function classifyBlockKind(node: PMNode): MeasuredBlock["kind"] {
266
+ switch (node.type.name) {
267
+ case "paragraph":
268
+ return "paragraph";
269
+ case "table":
270
+ return "table";
271
+ case "section_break":
272
+ return "section_break";
273
+ default:
274
+ return "opaque_block";
275
+ }
276
+ }
277
+
278
+ function isTableNode(node: PMNode): boolean {
279
+ return node.type.name === "table";
280
+ }
281
+
282
+ /**
283
+ * Measure per-line heights of a paragraph DOM node. The renderer uses
284
+ * standard `<p>` flow layout, so we approximate line heights by walking
285
+ * the paragraph's client rects via `Range.getClientRects` and grouping
286
+ * adjacent rects on the same baseline.
287
+ *
288
+ * Returns null if the paragraph has only one line (no split possible).
289
+ */
290
+ function measureParagraphLineHeights(el: HTMLElement): readonly number[] | null {
291
+ // Skip when the paragraph is empty or doesn't render multiple lines.
292
+ const range = document.createRange();
293
+ try {
294
+ range.selectNodeContents(el);
295
+ } catch {
296
+ return null;
297
+ }
298
+ const rects = Array.from(range.getClientRects());
299
+ if (rects.length <= 1) return null;
300
+
301
+ // Each rect roughly corresponds to a line in flow. Group by approximate
302
+ // top position to coalesce rects belonging to the same line that PM/CSS
303
+ // split across child spans.
304
+ const lines: number[] = [];
305
+ let currentTop: number | null = null;
306
+ let currentHeight = 0;
307
+ const TOLERANCE = 0.5; // px — same-baseline rect tolerance.
308
+ for (const r of rects) {
309
+ if (currentTop === null || Math.abs(r.top - currentTop) > TOLERANCE) {
310
+ if (currentTop !== null) lines.push(currentHeight);
311
+ currentTop = r.top;
312
+ currentHeight = r.height;
313
+ } else {
314
+ currentHeight = Math.max(currentHeight, r.height);
315
+ }
316
+ }
317
+ if (currentTop !== null) lines.push(currentHeight);
318
+ // If the line array is still singular, no split is possible.
319
+ if (lines.length <= 1) return null;
320
+ return lines;
321
+ }
322
+
323
+ /**
324
+ * Measure per-row heights of a table by reading offsetHeight from each
325
+ * `<tr>` child. Returns null when no rows are present.
326
+ */
327
+ function measureTableRowHeights(el: HTMLElement): readonly number[] | null {
328
+ const rows = el.querySelectorAll("tr");
329
+ if (rows.length === 0) return null;
330
+ const heights: number[] = [];
331
+ rows.forEach((row) => {
332
+ heights.push((row as HTMLElement).offsetHeight);
333
+ });
334
+ return heights;
335
+ }
336
+
337
+ // =========================================================================
338
+ // Decorations
339
+ // =========================================================================
340
+
341
+ function buildDecorations(
342
+ doc: PMNode,
343
+ layout: LayoutResult,
344
+ view: EditorView,
345
+ ): DecorationSet {
346
+ if (layout.pages.length <= 1) return DecorationSet.empty;
347
+
348
+ const decorations: Decoration[] = [];
349
+ // Build a list of block index → PM position for top-level blocks.
350
+ const positionByBlockIndex: number[] = [];
351
+ let pos = 0;
352
+ doc.forEach((node) => {
353
+ positionByBlockIndex.push(pos);
354
+ pos += node.nodeSize;
355
+ });
356
+
357
+ // Place a gap widget at the layout-determined boundary for every page
358
+ // after page 1. The engine-computed `gapHeightBefore` bridges the
359
+ // unused body-slot space on the previous page, the previous footer
360
+ // band, the visual gap between rectangles, and the next header band —
361
+ // so the flow position immediately after the widget equals the next
362
+ // page's body-slot top.
363
+ //
364
+ // Real-world wrinkle: the first block on a page often carries a CSS
365
+ // `margin-top` (Heading 1 / Title styles have one by default). With
366
+ // the widget at its engine-computed height, the block's box top would
367
+ // sit at body-slot top, but its CSS margin pushes the box DOWN by
368
+ // `margin-top` from the gap widget's bottom — overshooting by that
369
+ // amount. To compensate, we measure the next block's resolved
370
+ // `margin-top` and subtract it from the widget height. The CSS margin
371
+ // then "fills" the same space the widget would have occupied; the
372
+ // block's box lands exactly at body-slot top.
373
+ //
374
+ // When a page starts with the *continuation* of a block from the
375
+ // previous page (long paragraph spanning pages), the widget must be
376
+ // inserted MID-paragraph at the line boundary, not before the block —
377
+ // otherwise the entire paragraph slides to the next page. We use DOM
378
+ // measurement to find the PM position corresponding to the start of
379
+ // the next page's first line; falling back to block start when the
380
+ // measurement isn't possible.
381
+ for (let i = 1; i < layout.pages.length; i++) {
382
+ const page = layout.pages[i];
383
+ let widgetPos: number;
384
+ let blockDom: HTMLElement | null = null;
385
+ if (page.startsWithContinuation) {
386
+ const inlinePos = resolveInlineSplitPos({
387
+ view,
388
+ doc,
389
+ positionByBlockIndex,
390
+ slice: page.startsWithContinuation,
391
+ });
392
+ widgetPos =
393
+ inlinePos ??
394
+ positionByBlockIndex[page.startsWithContinuation.blockIndex] ??
395
+ 0;
396
+ const blockPos =
397
+ positionByBlockIndex[page.startsWithContinuation.blockIndex];
398
+ if (blockPos !== undefined) {
399
+ const dom = view.nodeDOM(blockPos);
400
+ if (dom instanceof HTMLElement) blockDom = dom;
401
+ }
402
+ } else {
403
+ const blockPos = positionByBlockIndex[page.firstBlockIndex];
404
+ widgetPos = blockPos ?? 0;
405
+ if (blockPos !== undefined) {
406
+ const dom = view.nodeDOM(blockPos);
407
+ if (dom instanceof HTMLElement) blockDom = dom;
408
+ }
409
+ }
410
+ const marginTopPx = blockDom ? readMarginTop(blockDom) : 0;
411
+ // Continuation paragraphs are visually a single block split across
412
+ // pages — the heading's margin-top doesn't apply mid-paragraph, so
413
+ // we skip the adjustment when there's a continuation.
414
+ const adjustedGap = page.startsWithContinuation
415
+ ? page.gapHeightBefore
416
+ : Math.max(0, page.gapHeightBefore - marginTopPx);
417
+ decorations.push(
418
+ Decoration.widget(widgetPos, gapWidget(adjustedGap, page.pageNumber), {
419
+ side: -1,
420
+ key: `page-gap-${page.pageNumber}-${adjustedGap}`,
421
+ ignoreSelection: true,
422
+ }),
423
+ );
424
+ }
425
+
426
+ return DecorationSet.create(doc, decorations);
427
+ }
428
+
429
+ /**
430
+ * Map a paragraph's `startsWithContinuation` slice to the PM position that
431
+ * starts the slice's first visual line in the rendered DOM.
432
+ *
433
+ * Strategy: measure per-line client rects of the paragraph via Range. For
434
+ * each rect, find the first DOM character whose bounding rect aligns with
435
+ * that line's top. The slice's `firstLine` index picks the target rect.
436
+ * `view.posAtDOM` translates the DOM offset to a PM position.
437
+ *
438
+ * Returns null when the paragraph isn't a paragraph node, the DOM isn't
439
+ * available (e.g., during initial mount), or measurement returns
440
+ * unexpected geometry (RTL, complex inline atoms). The caller falls back
441
+ * to block-start placement.
442
+ */
443
+ function resolveInlineSplitPos(args: {
444
+ view: EditorView;
445
+ doc: PMNode;
446
+ positionByBlockIndex: readonly number[];
447
+ slice: import("../layout/types").PartialBlockSlice;
448
+ }): number | null {
449
+ const blockPos = args.positionByBlockIndex[args.slice.blockIndex];
450
+ if (blockPos === undefined) return null;
451
+ const blockNode = args.doc.maybeChild(args.slice.blockIndex);
452
+ if (!blockNode || blockNode.type.name !== "paragraph") return null;
453
+
454
+ const dom = args.view.nodeDOM(blockPos);
455
+ const el = dom instanceof HTMLElement ? dom : null;
456
+ if (!el) return null;
457
+
458
+ const range = document.createRange();
459
+ range.selectNodeContents(el);
460
+ const rects = Array.from(range.getClientRects());
461
+ if (rects.length === 0) return null;
462
+ // Coalesce rects on the same baseline to per-line records.
463
+ const lines: number[] = []; // top Y of each line
464
+ const TOL = 0.5;
465
+ let currentTop: number | null = null;
466
+ for (const r of rects) {
467
+ if (currentTop === null || Math.abs(r.top - currentTop) > TOL) {
468
+ lines.push(r.top);
469
+ currentTop = r.top;
470
+ }
471
+ }
472
+ if (args.slice.firstLine >= lines.length) return null;
473
+ const targetTop = lines[args.slice.firstLine];
474
+
475
+ // Find the first text node character whose bounding rect aligns with
476
+ // `targetTop`. Walk text descendants in DOM order.
477
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
478
+ let node: Node | null = walker.nextNode();
479
+ while (node) {
480
+ const text = node.textContent ?? "";
481
+ for (let offset = 0; offset < text.length; offset++) {
482
+ const r = document.createRange();
483
+ r.setStart(node, offset);
484
+ r.setEnd(node, Math.min(offset + 1, text.length));
485
+ const rect = r.getBoundingClientRect();
486
+ if (rect.height > 0 && Math.abs(rect.top - targetTop) <= TOL) {
487
+ // Found a character on the target line. Convert to PM position.
488
+ // posAtDOM throws only for genuinely-invalid (node, offset) pairs
489
+ // that we never construct here — the walker gives us live text
490
+ // nodes from the editor's own DOM. If it throws, the doc state
491
+ // is inconsistent and we want a loud failure, not a silent
492
+ // mis-pagination.
493
+ return args.view.posAtDOM(node, offset);
494
+ }
495
+ }
496
+ node = walker.nextNode();
497
+ }
498
+ return null;
499
+ }
500
+
501
+ /**
502
+ * Read the computed `margin-top` (in CSS pixels) on a block element. We
503
+ * use this to compensate the gap widget for headings (and other styles)
504
+ * whose CSS margin would otherwise push the block past the chrome's
505
+ * body-slot top by that margin's width.
506
+ */
507
+ function readMarginTop(el: HTMLElement): number {
508
+ const raw = getComputedStyle(el).marginTop;
509
+ const parsed = Number.parseFloat(raw);
510
+ return Number.isFinite(parsed) ? parsed : 0;
511
+ }
512
+
513
+ function gapWidget(heightPx: number, pageNumber: number): () => HTMLElement {
514
+ return () => {
515
+ const el = document.createElement("div");
516
+ el.className = "docx-page-gap";
517
+ el.setAttribute("data-page-gap-after", String(pageNumber - 1));
518
+ el.setAttribute("aria-hidden", "true");
519
+ el.style.display = "block";
520
+ el.style.height = `${heightPx}px`;
521
+ el.style.width = "100%";
522
+ el.style.pointerEvents = "none";
523
+ return el;
524
+ };
525
+ }
526
+
527
+ // =========================================================================
528
+ // Helpers
529
+ // =========================================================================
530
+
531
+ function layoutsEqual(a: LayoutResult | null, b: LayoutResult): boolean {
532
+ if (a === null) return false;
533
+ if (a.totalPages !== b.totalPages) return false;
534
+ if (a.pages.length !== b.pages.length) return false;
535
+ for (let i = 0; i < a.pages.length; i++) {
536
+ const pa = a.pages[i];
537
+ const pb = b.pages[i];
538
+ if (pa.firstBlockIndex !== pb.firstBlockIndex) return false;
539
+ if (pa.lastBlockIndex !== pb.lastBlockIndex) return false;
540
+ if (pa.headerKind !== pb.headerKind) return false;
541
+ if (pa.sectionIndex !== pb.sectionIndex) return false;
542
+ // gapHeightBefore feeds the widget decoration's height; missing it
543
+ // here would let a font-load or measurement change leave stale gap
544
+ // widgets that no longer match the chrome's body-slot positions.
545
+ if (pa.gapHeightBefore !== pb.gapHeightBefore) return false;
546
+ if (
547
+ partialEqual(pa.startsWithContinuation, pb.startsWithContinuation) ===
548
+ false
549
+ )
550
+ return false;
551
+ if (partialEqual(pa.endsWithContinuation, pb.endsWithContinuation) === false)
552
+ return false;
553
+ }
554
+ return true;
555
+ }
556
+
557
+ function partialEqual(
558
+ a: import("../layout/types").PartialBlockSlice | null,
559
+ b: import("../layout/types").PartialBlockSlice | null,
560
+ ): boolean {
561
+ if (a === null && b === null) return true;
562
+ if (a === null || b === null) return false;
563
+ return (
564
+ a.blockIndex === b.blockIndex &&
565
+ a.firstLine === b.firstLine &&
566
+ a.lastLine === b.lastLine
567
+ );
568
+ }
569
+
570
+ /**
571
+ * Read the current pagination state from an EditorState. Public for the
572
+ * status bar, PageChromeManager hookup, and tests.
573
+ */
574
+ export function getPaginationState(
575
+ state: EditorState,
576
+ ): PaginationPluginState | null {
577
+ return paginationPluginKey.getState(state) ?? null;
578
+ }
579
+
580
+ /**
581
+ * Manually push a new layout result into the plugin state. Used by
582
+ * tests that bypass the rAF measurement loop.
583
+ */
584
+ export function setPaginationLayout(
585
+ view: EditorView,
586
+ layout: LayoutResult,
587
+ ): Transaction {
588
+ const decorations = buildDecorations(view.state.doc, layout, view);
589
+ return view.state.tr.setMeta(paginationPluginKey, { layout, decorations });
590
+ }