@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
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { parseDocx } from "./parse/parser";
2
+ export { serializeDocx } from "./serialize/serializer";
3
+ export type {
4
+ Block,
5
+ Body,
6
+ BodySectPr,
7
+ DocxDocument,
8
+ Inline,
9
+ OpaqueBlock,
10
+ OpaqueInline,
11
+ OpaqueRunChild,
12
+ Paragraph,
13
+ Run,
14
+ RunChild,
15
+ TextNode,
16
+ } from "./model/types";
@@ -0,0 +1,430 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ParagraphProperties } from "../model/properties";
3
+ import { EMPTY_PARAGRAPH_PROPERTIES } from "../model/properties";
4
+ import { runLayout } from "./engine";
5
+ import type {
6
+ MeasuredBlock,
7
+ SectionGeometry,
8
+ } from "./types";
9
+
10
+ // =========================================================================
11
+ // Test helpers
12
+ // =========================================================================
13
+
14
+ function section(
15
+ index: number,
16
+ blockStart: number,
17
+ blockEnd: number,
18
+ overrides: Partial<SectionGeometry> = {},
19
+ ): SectionGeometry {
20
+ return {
21
+ sectionIndex: index,
22
+ blockStartIndex: blockStart,
23
+ blockEndIndex: blockEnd,
24
+ pageHeight: 1056,
25
+ pageWidth: 816,
26
+ pageContentHeight: 900,
27
+ headerBand: 78,
28
+ footerBand: 78,
29
+ titlePage: false,
30
+ hasEvenHeader: false,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function para(
36
+ index: number,
37
+ height: number,
38
+ overrides: {
39
+ sectionIndex?: number;
40
+ properties?: Partial<ParagraphProperties>;
41
+ lineHeights?: readonly number[];
42
+ } = {},
43
+ ): MeasuredBlock {
44
+ return {
45
+ index,
46
+ sectionIndex: overrides.sectionIndex ?? 0,
47
+ kind: "paragraph",
48
+ height,
49
+ paragraphProperties: overrides.properties
50
+ ? { ...EMPTY_PARAGRAPH_PROPERTIES, ...overrides.properties }
51
+ : null,
52
+ lineHeights: overrides.lineHeights ?? null,
53
+ rowHeights: null,
54
+ };
55
+ }
56
+
57
+ function table(
58
+ index: number,
59
+ rowHeights: readonly number[],
60
+ overrides: { sectionIndex?: number } = {},
61
+ ): MeasuredBlock {
62
+ return {
63
+ index,
64
+ sectionIndex: overrides.sectionIndex ?? 0,
65
+ kind: "table",
66
+ height: rowHeights.reduce((a, b) => a + b, 0),
67
+ paragraphProperties: null,
68
+ lineHeights: null,
69
+ rowHeights,
70
+ };
71
+ }
72
+
73
+ // =========================================================================
74
+ // Empty / single-page cases
75
+ // =========================================================================
76
+
77
+ describe("runLayout — degenerate inputs", () => {
78
+ it("returns zero pages when no sections", () => {
79
+ const result = runLayout([], []);
80
+ expect(result.totalPages).toBe(0);
81
+ });
82
+
83
+ it("returns one page when there are no blocks", () => {
84
+ const result = runLayout([], [section(0, 0, 0)]);
85
+ expect(result.totalPages).toBe(1);
86
+ });
87
+
88
+ it("fits two short paragraphs on one page", () => {
89
+ const blocks = [para(0, 200), para(1, 200)];
90
+ const result = runLayout(blocks, [section(0, 0, 1)]);
91
+ expect(result.totalPages).toBe(1);
92
+ expect(result.pages[0].firstBlockIndex).toBe(0);
93
+ expect(result.pages[0].lastBlockIndex).toBe(1);
94
+ });
95
+ });
96
+
97
+ // =========================================================================
98
+ // Natural multi-page breaks
99
+ // =========================================================================
100
+
101
+ describe("runLayout — natural breaks", () => {
102
+ it("starts a new page when next block won't fit", () => {
103
+ // pageContentHeight = 900. Three blocks of 400 each: 400+400=800 fits;
104
+ // adding the third (400) -> 1200 > 900, so it moves to page 2.
105
+ const blocks = [para(0, 400), para(1, 400), para(2, 400)];
106
+ const result = runLayout(blocks, [section(0, 0, 2)]);
107
+ expect(result.totalPages).toBe(2);
108
+ expect(result.pages[0].lastBlockIndex).toBe(1);
109
+ expect(result.pages[1].firstBlockIndex).toBe(2);
110
+ });
111
+
112
+ it("packs many short paragraphs across multiple pages", () => {
113
+ const blocks = Array.from({ length: 10 }, (_, i) => para(i, 200));
114
+ const result = runLayout(blocks, [section(0, 0, 9)]);
115
+ // 4 paragraphs per page = 800px used; 5th doesn't fit (1000 > 900).
116
+ expect(result.totalPages).toBe(3);
117
+ expect(result.pages[0].firstBlockIndex).toBe(0);
118
+ expect(result.pages[0].lastBlockIndex).toBe(3);
119
+ expect(result.pages[1].firstBlockIndex).toBe(4);
120
+ expect(result.pages[1].lastBlockIndex).toBe(7);
121
+ expect(result.pages[2].firstBlockIndex).toBe(8);
122
+ expect(result.pages[2].lastBlockIndex).toBe(9);
123
+ });
124
+ });
125
+
126
+ // =========================================================================
127
+ // pageBreakBefore
128
+ // =========================================================================
129
+
130
+ describe("runLayout — pageBreakBefore", () => {
131
+ it("forces a new page when set on a paragraph mid-page", () => {
132
+ const blocks = [
133
+ para(0, 100),
134
+ para(1, 100, { properties: { pageBreakBefore: true } }),
135
+ ];
136
+ const result = runLayout(blocks, [section(0, 0, 1)]);
137
+ expect(result.totalPages).toBe(2);
138
+ expect(result.pages[0].lastBlockIndex).toBe(0);
139
+ expect(result.pages[1].firstBlockIndex).toBe(1);
140
+ });
141
+
142
+ it("does not create a blank page when pageBreakBefore is on the very first block", () => {
143
+ const blocks = [
144
+ para(0, 100, { properties: { pageBreakBefore: true } }),
145
+ para(1, 100),
146
+ ];
147
+ const result = runLayout(blocks, [section(0, 0, 1)]);
148
+ expect(result.totalPages).toBe(1);
149
+ });
150
+ });
151
+
152
+ // =========================================================================
153
+ // keepLines
154
+ // =========================================================================
155
+
156
+ describe("runLayout — keepLines", () => {
157
+ it("pushes a splittable paragraph whole when keepLines is set", () => {
158
+ // page already has 700 of content. Next block is a 400-tall paragraph
159
+ // with 4 lines of 100 each. Naturally it would split (200 here, 200
160
+ // on next page). With keepLines, it must move whole.
161
+ const blocks = [
162
+ para(0, 700),
163
+ para(1, 400, {
164
+ properties: { keepLines: true },
165
+ lineHeights: [100, 100, 100, 100],
166
+ }),
167
+ ];
168
+ const result = runLayout(blocks, [section(0, 0, 1)]);
169
+ expect(result.totalPages).toBe(2);
170
+ expect(result.pages[0].lastBlockIndex).toBe(0);
171
+ expect(result.pages[1].firstBlockIndex).toBe(1);
172
+ expect(result.pages[1].startsWithContinuation).toBeNull();
173
+ });
174
+
175
+ it("splits when keepLines is not set", () => {
176
+ const blocks = [
177
+ para(0, 700),
178
+ para(1, 400, { lineHeights: [100, 100, 100, 100] }),
179
+ ];
180
+ const result = runLayout(blocks, [section(0, 0, 1)]);
181
+ expect(result.totalPages).toBe(2);
182
+ // Page 1 should contain the start of block 1 (2 lines fit in remaining 200).
183
+ expect(result.pages[0].endsWithContinuation).toEqual({
184
+ blockIndex: 1,
185
+ firstLine: 0,
186
+ lastLine: 1,
187
+ });
188
+ expect(result.pages[1].startsWithContinuation).toEqual({
189
+ blockIndex: 1,
190
+ firstLine: 2,
191
+ lastLine: 3,
192
+ });
193
+ });
194
+ });
195
+
196
+ // =========================================================================
197
+ // Widow / orphan
198
+ // =========================================================================
199
+
200
+ describe("runLayout — widow/orphan", () => {
201
+ it("pushes a paragraph whole when only one line would fit (orphan)", () => {
202
+ // page has 800 of content; next paragraph has 4 lines of 100. Only 1
203
+ // line fits (100 <= remaining 100, 200 > 100). Orphan rule kicks in.
204
+ const blocks = [
205
+ para(0, 800),
206
+ para(1, 400, { lineHeights: [100, 100, 100, 100] }),
207
+ ];
208
+ const result = runLayout(blocks, [section(0, 0, 1)]);
209
+ expect(result.totalPages).toBe(2);
210
+ expect(result.pages[0].endsWithContinuation).toBeNull();
211
+ expect(result.pages[1].firstBlockIndex).toBe(1);
212
+ expect(result.pages[1].startsWithContinuation).toBeNull();
213
+ });
214
+
215
+ it("moves a short paragraph whole when no split would satisfy both min-lines rules", () => {
216
+ // 3-line paragraph, minLinesBeforeBreak=2, minLinesAfterBreak=2.
217
+ // 2 lines fit on current page, 1 would spill — but 1 < minLinesAfterBreak,
218
+ // and the widow rule's recomputed cut (1) is < minLinesBeforeBreak.
219
+ // The engine should push the whole paragraph to the next page rather
220
+ // than split into a widow/orphan-violating shape.
221
+ const blocks = [
222
+ para(0, 750),
223
+ para(1, 300, { lineHeights: [100, 100, 100] }),
224
+ ];
225
+ const result = runLayout(blocks, [section(0, 0, 1)]);
226
+ expect(result.totalPages).toBe(2);
227
+ expect(result.pages[0].endsWithContinuation).toBeNull();
228
+ expect(result.pages[1].firstBlockIndex).toBe(1);
229
+ expect(result.pages[1].startsWithContinuation).toBeNull();
230
+ });
231
+
232
+ it("pulls a line back when only one line would land on next page (widow)", () => {
233
+ // page is empty. Paragraph has 10 lines of 100 each. 9 fit (900); 1
234
+ // would widow on next page. Should cut at 8 instead.
235
+ const blocks = [
236
+ para(0, 1000, { lineHeights: Array(10).fill(100) }),
237
+ ];
238
+ const result = runLayout(blocks, [section(0, 0, 0)]);
239
+ expect(result.totalPages).toBe(2);
240
+ expect(result.pages[0].endsWithContinuation).toEqual({
241
+ blockIndex: 0,
242
+ firstLine: 0,
243
+ lastLine: 7,
244
+ });
245
+ expect(result.pages[1].startsWithContinuation).toEqual({
246
+ blockIndex: 0,
247
+ firstLine: 8,
248
+ lastLine: 9,
249
+ });
250
+ });
251
+ });
252
+
253
+ // =========================================================================
254
+ // keepNext
255
+ // =========================================================================
256
+
257
+ describe("runLayout — keepNext", () => {
258
+ it("pulls a heading forward when its body landed on the next page", () => {
259
+ // Heading (50 high, keepNext) lands at the end of page 1 with no
260
+ // room for any of the body. Body is on page 2. keepNext fix-up should
261
+ // move the heading to page 2 too.
262
+ const blocks = [
263
+ para(0, 850),
264
+ para(1, 50, { properties: { keepNext: true } }),
265
+ para(2, 800),
266
+ ];
267
+ const result = runLayout(blocks, [section(0, 0, 2)]);
268
+ expect(result.totalPages).toBe(2);
269
+ expect(result.pages[0].lastBlockIndex).toBe(0);
270
+ expect(result.pages[1].firstBlockIndex).toBe(1);
271
+ expect(result.pages[1].lastBlockIndex).toBe(2);
272
+ });
273
+
274
+ it("leaves blocks in place when keepNext is satisfied naturally", () => {
275
+ const blocks = [
276
+ para(0, 100, { properties: { keepNext: true } }),
277
+ para(1, 100),
278
+ ];
279
+ const result = runLayout(blocks, [section(0, 0, 1)]);
280
+ expect(result.totalPages).toBe(1);
281
+ });
282
+ });
283
+
284
+ // =========================================================================
285
+ // Tables — row-boundary splitting
286
+ // =========================================================================
287
+
288
+ describe("runLayout — tables", () => {
289
+ it("splits a table at the row boundary that fits", () => {
290
+ // Page has 400 used; remaining 500. Table has 7 rows of 100. 5 rows
291
+ // fit; 2 spill to page 2.
292
+ const blocks = [para(0, 400), table(1, Array(7).fill(100))];
293
+ const result = runLayout(blocks, [section(0, 0, 1)]);
294
+ expect(result.totalPages).toBe(2);
295
+ expect(result.pages[0].endsWithContinuation).toEqual({
296
+ blockIndex: 1,
297
+ firstLine: 0,
298
+ lastLine: 4,
299
+ });
300
+ expect(result.pages[1].startsWithContinuation).toEqual({
301
+ blockIndex: 1,
302
+ firstLine: 5,
303
+ lastLine: 6,
304
+ });
305
+ });
306
+
307
+ it("moves a table whole when zero rows fit on the current page", () => {
308
+ // Page has 850 used; remaining 50. First row is 100 (doesn't fit).
309
+ const blocks = [para(0, 850), table(1, [100, 100, 100])];
310
+ const result = runLayout(blocks, [section(0, 0, 1)]);
311
+ expect(result.totalPages).toBe(2);
312
+ expect(result.pages[0].endsWithContinuation).toBeNull();
313
+ expect(result.pages[1].firstBlockIndex).toBe(1);
314
+ expect(result.pages[1].startsWithContinuation).toBeNull();
315
+ });
316
+ });
317
+
318
+ // =========================================================================
319
+ // Multi-section
320
+ // =========================================================================
321
+
322
+ describe("runLayout — multi-section", () => {
323
+ it("starts a new page at section boundaries", () => {
324
+ const blocks = [
325
+ para(0, 100, { sectionIndex: 0 }),
326
+ para(1, 100, { sectionIndex: 1 }),
327
+ ];
328
+ const sections: SectionGeometry[] = [
329
+ section(0, 0, 0),
330
+ section(1, 1, 1),
331
+ ];
332
+ const result = runLayout(blocks, sections);
333
+ expect(result.totalPages).toBe(2);
334
+ expect(result.pages[0].sectionIndex).toBe(0);
335
+ expect(result.pages[1].sectionIndex).toBe(1);
336
+ expect(result.pages[1].isFirstInSection).toBe(true);
337
+ });
338
+
339
+ it("respects different page heights per section", () => {
340
+ // Section 0 has small content area (200), section 1 has large (900).
341
+ const blocks = [
342
+ para(0, 150, { sectionIndex: 0 }),
343
+ para(1, 150, { sectionIndex: 0 }),
344
+ para(2, 800, { sectionIndex: 1 }),
345
+ ];
346
+ const sections: SectionGeometry[] = [
347
+ section(0, 0, 1, { pageContentHeight: 200 }),
348
+ section(1, 2, 2, { pageContentHeight: 900 }),
349
+ ];
350
+ const result = runLayout(blocks, sections);
351
+ // Section 0: para(0)=150 fits; para(1)=150 doesn't fit on same page
352
+ // (would total 300 > 200), so it goes to page 2 of section 0.
353
+ // Section 1: para(2)=800 fits on its own page.
354
+ expect(result.totalPages).toBe(3);
355
+ expect(result.pages[0].sectionIndex).toBe(0);
356
+ expect(result.pages[1].sectionIndex).toBe(0);
357
+ expect(result.pages[2].sectionIndex).toBe(1);
358
+ expect(result.pages[2].isFirstInSection).toBe(true);
359
+ });
360
+ });
361
+
362
+ // =========================================================================
363
+ // Header kind selection
364
+ // =========================================================================
365
+
366
+ describe("runLayout — header kind", () => {
367
+ it("uses 'first' header on first page when titlePage is true", () => {
368
+ const blocks = [para(0, 100), para(1, 850), para(2, 100)];
369
+ const sections: SectionGeometry[] = [
370
+ section(0, 0, 2, { titlePage: true }),
371
+ ];
372
+ const result = runLayout(blocks, sections);
373
+ expect(result.pages[0].headerKind).toBe("first");
374
+ expect(result.pages[1].headerKind).toBe("default");
375
+ });
376
+
377
+ it("uses 'default' on first page when titlePage is false", () => {
378
+ const blocks = [para(0, 100)];
379
+ const sections: SectionGeometry[] = [section(0, 0, 0)];
380
+ const result = runLayout(blocks, sections);
381
+ expect(result.pages[0].headerKind).toBe("default");
382
+ });
383
+
384
+ it("uses 'even' header on even pages when hasEvenHeader is true", () => {
385
+ const blocks = [para(0, 850), para(1, 850), para(2, 100)];
386
+ const sections: SectionGeometry[] = [
387
+ section(0, 0, 2, { hasEvenHeader: true }),
388
+ ];
389
+ const result = runLayout(blocks, sections);
390
+ expect(result.pages[0].headerKind).toBe("default");
391
+ expect(result.pages[1].headerKind).toBe("even");
392
+ expect(result.pages[2].headerKind).toBe("default");
393
+ });
394
+ });
395
+
396
+ // =========================================================================
397
+ // Long single block (taller than a page)
398
+ // =========================================================================
399
+
400
+ describe("runLayout — block taller than one page", () => {
401
+ it("splits a paragraph across multiple pages line by line", () => {
402
+ // 15 lines of 100 = 1500 height; page content height = 900.
403
+ // First page: 9 lines fit; next 6 lines on page 2 would mean a widow
404
+ // of 6 on page 2 — passes the widow check (>= 2). Wait — that's
405
+ // actually fine. So page 1 has 9, page 2 has 6.
406
+ const blocks = [
407
+ para(0, 1500, { lineHeights: Array(15).fill(100) }),
408
+ ];
409
+ const result = runLayout(blocks, [section(0, 0, 0)]);
410
+ expect(result.totalPages).toBe(2);
411
+ expect(result.pages[0].startsWithContinuation).toBeNull();
412
+ expect(result.pages[0].endsWithContinuation?.lastLine).toBe(8);
413
+ expect(result.pages[1].startsWithContinuation?.firstLine).toBe(9);
414
+ });
415
+
416
+ it("splits across three pages when needed", () => {
417
+ // 20 lines of 100 each, page height 900. 9 + 9 + 2 — but widow rule
418
+ // (last page has 2 lines) is OK since min is 2.
419
+ const blocks = [
420
+ para(0, 2000, { lineHeights: Array(20).fill(100) }),
421
+ ];
422
+ const result = runLayout(blocks, [section(0, 0, 0)]);
423
+ expect(result.totalPages).toBe(3);
424
+ expect(result.pages[0].endsWithContinuation?.lastLine).toBe(8);
425
+ expect(result.pages[1].startsWithContinuation?.firstLine).toBe(9);
426
+ expect(result.pages[1].endsWithContinuation?.lastLine).toBe(17);
427
+ expect(result.pages[2].startsWithContinuation?.firstLine).toBe(18);
428
+ expect(result.pages[2].endsWithContinuation).toBeNull();
429
+ });
430
+ });