@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.
- package/package.json +40 -0
- package/src/fixtures/.gitkeep +0 -0
- package/src/fixtures/lotics_generated_contract.docx +0 -0
- package/src/fonts/bundled.ts +123 -0
- package/src/fonts/registry.test.ts +233 -0
- package/src/fonts/registry.ts +219 -0
- package/src/fonts/types.ts +83 -0
- package/src/index.ts +16 -0
- package/src/layout/engine.test.ts +430 -0
- package/src/layout/engine.ts +566 -0
- package/src/layout/page_geometry.ts +43 -0
- package/src/layout/types.ts +159 -0
- package/src/load.test.ts +144 -0
- package/src/load.ts +142 -0
- package/src/model/default_numbering.ts +101 -0
- package/src/model/default_styles.ts +201 -0
- package/src/model/numbering_table.ts +52 -0
- package/src/model/properties.ts +328 -0
- package/src/model/sections.ts +94 -0
- package/src/model/style_resolution.test.ts +219 -0
- package/src/model/style_resolution.ts +113 -0
- package/src/model/style_table.ts +22 -0
- package/src/model/theme.ts +156 -0
- package/src/model/types.ts +55 -0
- package/src/parse/drawing.ts +157 -0
- package/src/parse/font_table.ts +132 -0
- package/src/parse/footnotes.ts +60 -0
- package/src/parse/header_footer.test.ts +264 -0
- package/src/parse/header_footer.ts +66 -0
- package/src/parse/numbering.ts +187 -0
- package/src/parse/parser.ts +184 -0
- package/src/parse/relationships.ts +83 -0
- package/src/parse/sections.test.ts +192 -0
- package/src/parse/sections.ts +182 -0
- package/src/parse/styles.ts +149 -0
- package/src/parse/theme.test.ts +86 -0
- package/src/parse/theme.ts +112 -0
- package/src/pm/bubble_menu.ts +117 -0
- package/src/pm/commands.test.ts +185 -0
- package/src/pm/commands.ts +697 -0
- package/src/pm/commands_insert.test.ts +183 -0
- package/src/pm/docx_to_pm.test.ts +330 -0
- package/src/pm/docx_to_pm.ts +643 -0
- package/src/pm/drag_handle.ts +166 -0
- package/src/pm/format_painter.test.ts +91 -0
- package/src/pm/format_painter.ts +109 -0
- package/src/pm/header_footer_doc.ts +24 -0
- package/src/pm/hyperlinks.test.ts +234 -0
- package/src/pm/image_registry.test.ts +81 -0
- package/src/pm/image_registry.ts +100 -0
- package/src/pm/images.test.ts +257 -0
- package/src/pm/link_popover.ts +159 -0
- package/src/pm/mark_commands.ts +60 -0
- package/src/pm/marks.ts +169 -0
- package/src/pm/nodes.ts +258 -0
- package/src/pm/numbering.test.ts +210 -0
- package/src/pm/numbering_plugin.test.ts +71 -0
- package/src/pm/numbering_plugin.ts +96 -0
- package/src/pm/outline.ts +41 -0
- package/src/pm/page_break.test.ts +80 -0
- package/src/pm/page_layout.test.ts +87 -0
- package/src/pm/pagination_plugin.test.ts +155 -0
- package/src/pm/pagination_plugin.ts +590 -0
- package/src/pm/phase5.test.ts +271 -0
- package/src/pm/phase6.test.ts +215 -0
- package/src/pm/placeholder_plugin.ts +24 -0
- package/src/pm/plugins.ts +91 -0
- package/src/pm/pm_to_docx.ts +0 -0
- package/src/pm/roundtrip.test.ts +332 -0
- package/src/pm/schema.test.ts +188 -0
- package/src/pm/schema.ts +79 -0
- package/src/pm/search.ts +46 -0
- package/src/pm/table_attrs.ts +48 -0
- package/src/pm/table_borders.test.ts +117 -0
- package/src/pm/table_borders.ts +130 -0
- package/src/pm/table_convert.test.ts +221 -0
- package/src/pm/table_convert.ts +541 -0
- package/src/pm/table_decorations.ts +132 -0
- package/src/pm/table_handles.ts +163 -0
- package/src/pm/template_marker.ts +47 -0
- package/src/pm/template_plugin.ts +65 -0
- package/src/pm/templates.test.ts +162 -0
- package/src/render/clipboard.test.ts +115 -0
- package/src/render/clipboard.ts +200 -0
- package/src/render/editable_view.test.ts +173 -0
- package/src/render/footnotes_view.ts +94 -0
- package/src/render/header_footer_view.ts +95 -0
- package/src/render/link_mark_view.ts +26 -0
- package/src/render/media_resolver.ts +61 -0
- package/src/render/node_views.ts +296 -0
- package/src/render/numbering_counter.ts +149 -0
- package/src/render/page_chrome.test.ts +262 -0
- package/src/render/page_chrome.ts +343 -0
- package/src/render/page_styles.ts +234 -0
- package/src/render/paragraph_view.test.ts +162 -0
- package/src/render/paragraph_view.ts +141 -0
- package/src/render/ruler.ts +110 -0
- package/src/render/style_registry.ts +33 -0
- package/src/render/table_dom.test.ts +171 -0
- package/src/render/table_dom.ts +288 -0
- package/src/render/units.ts +18 -0
- package/src/render/view.test.ts +165 -0
- package/src/render/view.ts +607 -0
- package/src/roundtrip.test.ts +179 -0
- package/src/serialize/default_parts.ts +128 -0
- package/src/serialize/header_footer_pm.ts +82 -0
- 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
|
+
}
|