@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,566 @@
1
+ import type {
2
+ HeaderFooterKind,
3
+ LayoutOptions,
4
+ LayoutResult,
5
+ MeasuredBlock,
6
+ Page,
7
+ PartialBlockSlice,
8
+ SectionGeometry,
9
+ } from "./types";
10
+ import { DEFAULT_LAYOUT_OPTIONS } from "./types";
11
+
12
+
13
+ /**
14
+ * Pure page-break planner.
15
+ *
16
+ * The engine consumes measured block heights, section geometry, and the
17
+ * paragraph-level layout properties parsed from OOXML, and returns the
18
+ * sequence of pages plus which block (or slice) starts and ends each page.
19
+ *
20
+ * The renderer consumes the result to emit widget decorations at page
21
+ * boundaries and to mount the per-page chrome rectangles behind the editor
22
+ * content. The engine itself never touches the DOM.
23
+ *
24
+ * Algorithm:
25
+ * 1. Greedy fill: pack blocks onto pages by accumulating heights against
26
+ * the section's content area. When a block doesn't fit, decide whether
27
+ * it can be split (paragraphs, tables) or must move whole.
28
+ * 2. Apply paragraph break rules during the same pass:
29
+ * - `pageBreakBefore`: force a new page before this block.
30
+ * - `keepLines`: a paragraph that would split must move whole instead.
31
+ * - widow/orphan: a paragraph may not leave < `minLinesBeforeBreak`
32
+ * lines on the current page nor < `minLinesAfterBreak` on the next.
33
+ * 3. Fix-up pass for `keepNext`: bind a paragraph to the following block.
34
+ * If they end up on different pages, push the earlier one forward.
35
+ *
36
+ * Complexity is O(N) in the number of blocks. The keepNext fix-up is at
37
+ * most O(N) on a single linear sweep because we never re-evaluate blocks
38
+ * we've already settled.
39
+ */
40
+ export function runLayout(
41
+ blocks: readonly MeasuredBlock[],
42
+ sectionGeometries: readonly SectionGeometry[],
43
+ options: LayoutOptions = DEFAULT_LAYOUT_OPTIONS,
44
+ ): LayoutResult {
45
+ if (sectionGeometries.length === 0) {
46
+ return { pages: [], totalPages: 0 };
47
+ }
48
+
49
+ const builder = new PageBuilder(sectionGeometries, options);
50
+
51
+ for (const block of blocks) {
52
+ builder.place(block);
53
+ }
54
+ builder.finalize();
55
+
56
+ const pagesAfterKeepNext = applyKeepNext(builder.pages, blocks, options);
57
+ return {
58
+ pages: pagesAfterKeepNext,
59
+ totalPages: pagesAfterKeepNext.length,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * After all placement (greedy fill + keepNext fix-up) is complete, walk
65
+ * pages in order and compute the gap each page's first block needs in
66
+ * flow coordinates so that block lands at the chrome layer's body-slot
67
+ * top for that page.
68
+ *
69
+ * The editor content's flow Y at the END of page N's last block equals:
70
+ * mountPaddingTop + heightUsed_N
71
+ * = page_N_body_slot_top + heightUsed_N (since mountPaddingTop = headerBand_first)
72
+ *
73
+ * The next block must land at page (N+1)'s body slot top:
74
+ * page_N+1_body_slot_top = page_N_chrome_top + pageHeight_N + visualGap + headerBand_N+1
75
+ *
76
+ * Solving for the gap:
77
+ * gap = page_N+1_body_slot_top - (page_N_body_slot_top + heightUsed_N)
78
+ * = (pageContentHeight_N - heightUsed_N) // unused body slot
79
+ * + footerBand_N // band below body slot
80
+ * + visualGap // gray space between rectangles
81
+ * + headerBand_N+1 // band above next body slot
82
+ */
83
+ function computeGapHeights(
84
+ workingPages: readonly WorkingPage[],
85
+ visualPageGapPx: number,
86
+ ): readonly WorkingPage[] {
87
+ if (workingPages.length === 0) return workingPages;
88
+ const out = workingPages.map((p) => ({ ...p }));
89
+ for (let i = 1; i < out.length; i++) {
90
+ const prev = out[i - 1];
91
+ const cur = out[i];
92
+ const unusedOnPrev = Math.max(
93
+ 0,
94
+ prev.geometry.pageContentHeight - prev.heightUsed,
95
+ );
96
+ cur.gapHeightBefore =
97
+ unusedOnPrev +
98
+ prev.geometry.footerBand +
99
+ visualPageGapPx +
100
+ cur.geometry.headerBand;
101
+ }
102
+ return out;
103
+ }
104
+
105
+ // =========================================================================
106
+ // PageBuilder — internal, mutable scratch space for the greedy fill pass.
107
+ // =========================================================================
108
+
109
+ type WorkingPage = {
110
+ pageNumber: number;
111
+ sectionIndex: number;
112
+ firstBlockIndex: number;
113
+ lastBlockIndex: number;
114
+ startsWithContinuation: PartialBlockSlice | null;
115
+ endsWithContinuation: PartialBlockSlice | null;
116
+ headerKind: HeaderFooterKind;
117
+ isFirstInSection: boolean;
118
+ heightUsed: number;
119
+ geometry: SectionGeometry;
120
+ empty: boolean;
121
+ gapHeightBefore: number;
122
+ };
123
+
124
+ class PageBuilder {
125
+ readonly pages: WorkingPage[] = [];
126
+ private current: WorkingPage;
127
+ private sectionPageCount = new Map<number, number>();
128
+
129
+ constructor(
130
+ private readonly sections: readonly SectionGeometry[],
131
+ private readonly opts: LayoutOptions,
132
+ ) {
133
+ this.current = this.openPage(sections[0]);
134
+ }
135
+
136
+ /**
137
+ * Place a block onto the working page. May close the current page and
138
+ * open one or more new pages if the block doesn't fit or contains a
139
+ * forced break.
140
+ */
141
+ place(block: MeasuredBlock): void {
142
+ // Section transition. The OOXML parser groups blocks into sections; a
143
+ // change in `block.sectionIndex` marks a hard section boundary that
144
+ // forces a page break (sections may also have different page sizes).
145
+ if (block.sectionIndex !== this.current.sectionIndex) {
146
+ this.closeCurrent();
147
+ this.current = this.openPage(this.sections[block.sectionIndex]);
148
+ }
149
+
150
+ // Honor `w:pageBreakBefore`. The only exception is the very first
151
+ // block of a section, where forcing a break would create a blank
152
+ // leading page.
153
+ if (
154
+ block.paragraphProperties?.pageBreakBefore === true &&
155
+ !this.current.empty
156
+ ) {
157
+ this.closeCurrent();
158
+ this.current = this.openPage(this.current.geometry);
159
+ }
160
+
161
+ if (block.kind === "section_break") {
162
+ this.closeCurrent();
163
+ this.current = this.openPage(this.sections[block.sectionIndex]);
164
+ return;
165
+ }
166
+
167
+ this.placeRespectingBreakRules(block);
168
+ }
169
+
170
+ private placeRespectingBreakRules(block: MeasuredBlock): void {
171
+ const remaining =
172
+ this.current.geometry.pageContentHeight - this.current.heightUsed;
173
+
174
+ // Block fits whole on the current page.
175
+ if (block.height <= remaining) {
176
+ this.appendWhole(block);
177
+ return;
178
+ }
179
+
180
+ // Block doesn't fit and cannot be split — push to next page whole.
181
+ if (!isSplittable(block)) {
182
+ this.closeCurrent();
183
+ this.current = this.openPage(this.current.geometry);
184
+ this.appendWhole(block);
185
+ return;
186
+ }
187
+
188
+ // Block doesn't fit but could be split. Apply paragraph rules.
189
+ if (block.kind === "paragraph") {
190
+ this.splitParagraph(block, remaining);
191
+ return;
192
+ }
193
+
194
+ if (block.kind === "table") {
195
+ this.splitTable(block, remaining);
196
+ return;
197
+ }
198
+ }
199
+
200
+ // -------- Paragraph splitting (line-level) ---------------------------
201
+
202
+ private splitParagraph(block: MeasuredBlock, remaining: number): void {
203
+ const lineHeights = block.lineHeights;
204
+ if (!lineHeights || lineHeights.length === 0) {
205
+ // Defensive — shouldn't reach here because `isSplittable` checks.
206
+ this.appendWhole(block);
207
+ return;
208
+ }
209
+
210
+ const linesThatFit = countLinesThatFit(lineHeights, remaining);
211
+ const linesRemaining = lineHeights.length - linesThatFit;
212
+
213
+ // `keepLines`: paragraph must move whole rather than split. If it
214
+ // already doesn't fit anywhere, treat the whole-page placement as
215
+ // the best we can do (matches Word's behavior).
216
+ if (block.paragraphProperties?.keepLines === true) {
217
+ if (!this.current.empty) {
218
+ this.closeCurrent();
219
+ this.current = this.openPage(this.current.geometry);
220
+ }
221
+ this.appendWhole(block);
222
+ return;
223
+ }
224
+
225
+ // Orphan rule: too few lines fit on the current page. Push whole.
226
+ if (
227
+ linesThatFit > 0 &&
228
+ linesThatFit < this.opts.minLinesBeforeBreak &&
229
+ !this.current.empty
230
+ ) {
231
+ this.closeCurrent();
232
+ this.current = this.openPage(this.current.geometry);
233
+ this.appendWhole(block);
234
+ return;
235
+ }
236
+
237
+ // Widow rule: only a tiny tail would land on the next page. Pull more
238
+ // lines forward to the next page by reducing what fits here.
239
+ let cut = linesThatFit;
240
+ if (
241
+ linesRemaining > 0 &&
242
+ linesRemaining < this.opts.minLinesAfterBreak &&
243
+ cut > this.opts.minLinesBeforeBreak
244
+ ) {
245
+ cut = Math.max(
246
+ this.opts.minLinesBeforeBreak,
247
+ lineHeights.length - this.opts.minLinesAfterBreak,
248
+ );
249
+ }
250
+
251
+ // Validate that the split respects BOTH minima. When the paragraph
252
+ // is too short for any legal split (total lines < before+after), no
253
+ // split satisfies both rules — fall back to moving the whole block
254
+ // to the next page so it renders intact. Without this guard, a 3-line
255
+ // paragraph with minLines=2 would produce a 2/1 split (widow) or a
256
+ // 1/2 split (orphan).
257
+ if (
258
+ cut === 0 ||
259
+ cut < this.opts.minLinesBeforeBreak ||
260
+ lineHeights.length - cut < this.opts.minLinesAfterBreak
261
+ ) {
262
+ if (!this.current.empty) {
263
+ this.closeCurrent();
264
+ this.current = this.openPage(this.current.geometry);
265
+ }
266
+ this.appendWhole(block);
267
+ return;
268
+ }
269
+
270
+ // Split: `cut` lines on this page, the rest on subsequent page(s).
271
+ const heightOnThisPage = sumLines(lineHeights, 0, cut);
272
+ this.current.endsWithContinuation = {
273
+ blockIndex: block.index,
274
+ firstLine: 0,
275
+ lastLine: cut - 1,
276
+ };
277
+ this.current.lastBlockIndex = block.index;
278
+ this.current.heightUsed += heightOnThisPage;
279
+ this.current.empty = false;
280
+
281
+ // Carry remaining lines onto subsequent pages.
282
+ let nextStart = cut;
283
+ while (nextStart < lineHeights.length) {
284
+ this.closeCurrent();
285
+ this.current = this.openPage(this.current.geometry);
286
+ const continuationRemaining = this.current.geometry.pageContentHeight;
287
+ const linesFitNext = countLinesThatFit(
288
+ lineHeights.slice(nextStart),
289
+ continuationRemaining,
290
+ );
291
+ // Defensive: if even one line is taller than a whole page, place
292
+ // exactly one line per page so we don't loop forever.
293
+ const taken = Math.max(1, linesFitNext);
294
+ const lastIdx = Math.min(
295
+ nextStart + taken - 1,
296
+ lineHeights.length - 1,
297
+ );
298
+ this.current.firstBlockIndex = block.index;
299
+ this.current.lastBlockIndex = block.index;
300
+ this.current.startsWithContinuation = {
301
+ blockIndex: block.index,
302
+ firstLine: nextStart,
303
+ lastLine: lastIdx,
304
+ };
305
+ const usedNext = sumLines(lineHeights, nextStart, lastIdx + 1);
306
+ this.current.heightUsed = usedNext;
307
+ this.current.empty = false;
308
+ // If this is the final chunk, do not set endsWithContinuation; the
309
+ // block has finished on this page.
310
+ if (lastIdx < lineHeights.length - 1) {
311
+ this.current.endsWithContinuation = {
312
+ blockIndex: block.index,
313
+ firstLine: nextStart,
314
+ lastLine: lastIdx,
315
+ };
316
+ }
317
+ nextStart = lastIdx + 1;
318
+ }
319
+ }
320
+
321
+ // -------- Table splitting (row-level) --------------------------------
322
+
323
+ private splitTable(block: MeasuredBlock, remaining: number): void {
324
+ const rowHeights = block.rowHeights;
325
+ if (!rowHeights || rowHeights.length === 0) {
326
+ this.appendWhole(block);
327
+ return;
328
+ }
329
+ const rowsThatFit = countLinesThatFit(rowHeights, remaining);
330
+
331
+ // If at least one row fits, split at that row. Otherwise move whole.
332
+ if (rowsThatFit === 0) {
333
+ if (!this.current.empty) {
334
+ this.closeCurrent();
335
+ this.current = this.openPage(this.current.geometry);
336
+ }
337
+ this.appendWhole(block);
338
+ return;
339
+ }
340
+
341
+ this.current.endsWithContinuation = {
342
+ blockIndex: block.index,
343
+ firstLine: 0,
344
+ lastLine: rowsThatFit - 1,
345
+ };
346
+ this.current.lastBlockIndex = block.index;
347
+ this.current.heightUsed += sumLines(rowHeights, 0, rowsThatFit);
348
+ this.current.empty = false;
349
+
350
+ let nextStart = rowsThatFit;
351
+ while (nextStart < rowHeights.length) {
352
+ this.closeCurrent();
353
+ this.current = this.openPage(this.current.geometry);
354
+ const fitNext = countLinesThatFit(
355
+ rowHeights.slice(nextStart),
356
+ this.current.geometry.pageContentHeight,
357
+ );
358
+ const taken = Math.max(1, fitNext);
359
+ const lastIdx = Math.min(
360
+ nextStart + taken - 1,
361
+ rowHeights.length - 1,
362
+ );
363
+ this.current.firstBlockIndex = block.index;
364
+ this.current.lastBlockIndex = block.index;
365
+ this.current.startsWithContinuation = {
366
+ blockIndex: block.index,
367
+ firstLine: nextStart,
368
+ lastLine: lastIdx,
369
+ };
370
+ this.current.heightUsed = sumLines(rowHeights, nextStart, lastIdx + 1);
371
+ this.current.empty = false;
372
+ if (lastIdx < rowHeights.length - 1) {
373
+ this.current.endsWithContinuation = {
374
+ blockIndex: block.index,
375
+ firstLine: nextStart,
376
+ lastLine: lastIdx,
377
+ };
378
+ }
379
+ nextStart = lastIdx + 1;
380
+ }
381
+ }
382
+
383
+ // -------- Page life-cycle --------------------------------------------
384
+
385
+ private appendWhole(block: MeasuredBlock): void {
386
+ if (this.current.empty) {
387
+ this.current.firstBlockIndex = block.index;
388
+ }
389
+ this.current.lastBlockIndex = block.index;
390
+ this.current.heightUsed += block.height;
391
+ this.current.empty = false;
392
+ }
393
+
394
+ private openPage(geometry: SectionGeometry): WorkingPage {
395
+ const sectionPageNumber =
396
+ (this.sectionPageCount.get(geometry.sectionIndex) ?? 0) + 1;
397
+ this.sectionPageCount.set(geometry.sectionIndex, sectionPageNumber);
398
+ const isFirstInSection = sectionPageNumber === 1;
399
+ const pageNumber = this.pages.length + 1;
400
+ return {
401
+ pageNumber,
402
+ sectionIndex: geometry.sectionIndex,
403
+ firstBlockIndex: geometry.blockStartIndex,
404
+ lastBlockIndex: geometry.blockStartIndex,
405
+ startsWithContinuation: null,
406
+ endsWithContinuation: null,
407
+ headerKind: pickHeaderKind({
408
+ pageNumber,
409
+ isFirstInSection,
410
+ geometry,
411
+ }),
412
+ isFirstInSection,
413
+ heightUsed: 0,
414
+ geometry,
415
+ empty: true,
416
+ // Filled in by `finalizeGapHeights` after keepNext settles. The
417
+ // gap depends on the previous page's final heightUsed, which can
418
+ // change during the keepNext fix-up pass, so we defer.
419
+ gapHeightBefore: 0,
420
+ };
421
+ }
422
+
423
+ private closeCurrent(): void {
424
+ if (this.current.empty) {
425
+ // Don't push empty leading pages, but always push if we've already
426
+ // accumulated pages (preserves the section's first-page slot even
427
+ // when it was forcibly broken from immediately).
428
+ if (this.pages.length === 0) return;
429
+ }
430
+ this.pages.push(this.current);
431
+ }
432
+
433
+ finalize(): void {
434
+ if (!this.current.empty || this.pages.length === 0) {
435
+ this.pages.push(this.current);
436
+ }
437
+ }
438
+ }
439
+
440
+ // =========================================================================
441
+ // keepNext fix-up (second pass)
442
+ // =========================================================================
443
+
444
+ /**
445
+ * `w:keepNext` binds a paragraph to the start of the next block. If the
446
+ * greedy pass left the paragraph at the end of page N and the next block
447
+ * starts on page N+1, push the paragraph forward to page N+1 as well.
448
+ *
449
+ * Implementation: walk pages back-to-front. For each page, if its last
450
+ * block has `keepNext` and the next page's first block is the block
451
+ * immediately after it, move the last block forward.
452
+ *
453
+ * We bound the fix-up: only one pass. Chains of keepNext can in theory
454
+ * cascade (A keepNext B keepNext C and C moves), but cascading shoves are
455
+ * rare and an extra page on the previous page is the only side effect.
456
+ * A second pass would handle them; for MVP we accept the rare cosmetic
457
+ * drift.
458
+ */
459
+ function applyKeepNext(
460
+ pages: readonly WorkingPage[],
461
+ blocks: readonly MeasuredBlock[],
462
+ options: LayoutOptions,
463
+ ): readonly Page[] {
464
+ if (pages.length === 0) return [];
465
+ if (pages.length < 2) {
466
+ const single = computeGapHeights(pages, options.visualPageGapPx);
467
+ return single.map(workingPageToPage);
468
+ }
469
+ const mutable: WorkingPage[] = pages.map((p) => ({ ...p }));
470
+
471
+ for (let i = mutable.length - 2; i >= 0; i--) {
472
+ const page = mutable[i];
473
+ const next = mutable[i + 1];
474
+ if (page.endsWithContinuation !== null) continue;
475
+ const lastBlock = blocks[page.lastBlockIndex];
476
+ if (!lastBlock?.paragraphProperties?.keepNext) continue;
477
+ if (next.firstBlockIndex !== page.lastBlockIndex + 1) continue;
478
+ if (next.startsWithContinuation !== null) continue;
479
+
480
+ // Move `lastBlock` from page i to page i+1's start, if it fits.
481
+ const nextRemaining =
482
+ next.geometry.pageContentHeight - next.heightUsed;
483
+ if (lastBlock.height > nextRemaining) continue;
484
+
485
+ page.heightUsed -= lastBlock.height;
486
+ page.lastBlockIndex -= 1;
487
+ // If the page is now empty, drop it. Otherwise leave it.
488
+ if (page.lastBlockIndex < page.firstBlockIndex) {
489
+ page.empty = true;
490
+ }
491
+
492
+ next.firstBlockIndex = lastBlock.index;
493
+ next.heightUsed += lastBlock.height;
494
+ }
495
+
496
+ // Drop pages that became empty after keepNext moves.
497
+ const filtered = mutable.filter((p) => !p.empty);
498
+ // Re-number pages after any drops, then compute gap heights against the
499
+ // final (post-keepNext) heightUsed values.
500
+ const renumbered = filtered.map((p, idx) => ({ ...p, pageNumber: idx + 1 }));
501
+ return computeGapHeights(renumbered, options.visualPageGapPx).map(
502
+ workingPageToPage,
503
+ );
504
+ }
505
+
506
+ function workingPageToPage(p: WorkingPage): Page {
507
+ return {
508
+ pageNumber: p.pageNumber,
509
+ sectionIndex: p.sectionIndex,
510
+ firstBlockIndex: p.firstBlockIndex,
511
+ lastBlockIndex: p.lastBlockIndex,
512
+ headerKind: p.headerKind,
513
+ isFirstInSection: p.isFirstInSection,
514
+ startsWithContinuation: p.startsWithContinuation,
515
+ endsWithContinuation: p.endsWithContinuation,
516
+ gapHeightBefore: p.gapHeightBefore,
517
+ };
518
+ }
519
+
520
+ // =========================================================================
521
+ // Helpers
522
+ // =========================================================================
523
+
524
+ function pickHeaderKind(args: {
525
+ pageNumber: number;
526
+ isFirstInSection: boolean;
527
+ geometry: SectionGeometry;
528
+ }): HeaderFooterKind {
529
+ if (args.isFirstInSection && args.geometry.titlePage) return "first";
530
+ if (args.geometry.hasEvenHeader && args.pageNumber % 2 === 0) return "even";
531
+ return "default";
532
+ }
533
+
534
+ function isSplittable(block: MeasuredBlock): boolean {
535
+ if (block.kind === "paragraph") {
536
+ return (block.lineHeights?.length ?? 0) > 1;
537
+ }
538
+ if (block.kind === "table") {
539
+ return (block.rowHeights?.length ?? 0) > 1;
540
+ }
541
+ return false;
542
+ }
543
+
544
+ function countLinesThatFit(
545
+ heights: readonly number[],
546
+ budget: number,
547
+ ): number {
548
+ let total = 0;
549
+ for (let i = 0; i < heights.length; i++) {
550
+ if (total + heights[i] > budget) return i;
551
+ total += heights[i];
552
+ }
553
+ return heights.length;
554
+ }
555
+
556
+ function sumLines(
557
+ heights: readonly number[],
558
+ fromInclusive: number,
559
+ toExclusive: number,
560
+ ): number {
561
+ let total = 0;
562
+ for (let i = fromInclusive; i < toExclusive; i++) {
563
+ total += heights[i];
564
+ }
565
+ return total;
566
+ }
@@ -0,0 +1,43 @@
1
+ import type { Section } from "../model/sections";
2
+ import { dxaToPx } from "../render/units";
3
+ import type { SectionGeometry } from "./types";
4
+
5
+ /**
6
+ * Derive per-section page geometry in CSS pixels from the parsed
7
+ * SectionProperties (which use OOXML dxa units throughout).
8
+ *
9
+ * The result is intentionally a plain readonly array, indexed by
10
+ * `sectionIndex` so the engine can look up a section's geometry in O(1)
11
+ * during the layout pass.
12
+ */
13
+ export function computeSectionGeometries(
14
+ sections: readonly Section[],
15
+ ): readonly SectionGeometry[] {
16
+ return sections.map((section, sectionIndex) => {
17
+ const { pageSize, margins, headerRefs, footerRefs, titlePage } =
18
+ section.properties;
19
+ const pageHeight = dxaToPx(pageSize.height);
20
+ const pageWidth = dxaToPx(pageSize.width);
21
+ const marginTop = dxaToPx(margins.top);
22
+ const marginBottom = dxaToPx(margins.bottom);
23
+ // The header band is the space between the page edge and the start of
24
+ // content. Word reserves `margin.top` even when there is no header, so
25
+ // we always set headerBand = marginTop. Same applies to footers.
26
+ const headerBand = marginTop;
27
+ const footerBand = marginBottom;
28
+ const pageContentHeight = pageHeight - headerBand - footerBand;
29
+
30
+ return {
31
+ sectionIndex,
32
+ blockStartIndex: section.blockStartIndex,
33
+ blockEndIndex: section.blockEndIndex,
34
+ pageHeight,
35
+ pageWidth,
36
+ pageContentHeight,
37
+ headerBand,
38
+ footerBand,
39
+ titlePage,
40
+ hasEvenHeader: headerRefs.some((r) => r.type === "even"),
41
+ };
42
+ });
43
+ }