@beyondwork/docx-react-component 1.0.36 → 1.0.37
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/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +83 -0
- package/src/core/commands/index.ts +18 -1
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +337 -50
- package/src/io/export/serialize-numbering.ts +115 -24
- package/src/io/export/serialize-tables.ts +13 -11
- package/src/io/export/table-properties-xml.ts +35 -16
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +5 -0
- package/src/io/ooxml/parse-footnotes.ts +2 -1
- package/src/io/ooxml/parse-headers-footers.ts +2 -1
- package/src/io/ooxml/parse-main-document.ts +21 -1
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +11 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-navigation.ts +1 -305
- package/src/runtime/document-runtime.ts +173 -11
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +188 -0
- package/src/runtime/layout/inert-layout-facet.ts +45 -0
- package/src/runtime/layout/layout-engine-instance.ts +618 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +433 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +788 -0
- package/src/runtime/layout/public-facet.ts +705 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/ui/WordReviewEditor.tsx +15 -0
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +235 -0
- package/src/ui/headless/scoped-chrome-policy.ts +164 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
- package/src/ui-tailwind/theme/editor-theme.css +40 -14
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
- package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-owned paginated layout engine facade.
|
|
3
|
+
*
|
|
4
|
+
* `buildPageStack` is the stateless entry point. A stateful `LayoutEngineInstance`
|
|
5
|
+
* layer in `./layout-engine-instance.ts` wraps this for `DocumentRuntime` use,
|
|
6
|
+
* adding the graph, story resolver, fragment mapper, and invalidation pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Section-break fidelity implemented here:
|
|
9
|
+
* - `nextPage` (default) — new page set per section
|
|
10
|
+
* - `evenPage` / `oddPage` — blank filler pages to meet parity
|
|
11
|
+
* - `continuous` — the new section continues on the previous section's last page
|
|
12
|
+
* - `nextColumn` — treated as `continuous` (column advance still TODO for
|
|
13
|
+
* true multi-column typeset; see `paginateSectionBlocks`)
|
|
14
|
+
*
|
|
15
|
+
* Paragraph-level pagination hints:
|
|
16
|
+
* - `keepNext` — already honored in `paginateSectionBlocks`
|
|
17
|
+
* - `keepLines` — already honored
|
|
18
|
+
* - `pageBreakBefore` — already honored
|
|
19
|
+
* - `widowControl` — applied by `applyWidowControlPass` after pagination
|
|
20
|
+
*
|
|
21
|
+
* Known remaining limitations:
|
|
22
|
+
* - Measurement uses empirical character-width tables by default; Canvas +
|
|
23
|
+
* FontFace is available via `LayoutMeasurementProvider` but not yet
|
|
24
|
+
* integrated into `measureBlockHeight()`.
|
|
25
|
+
* - Full recompute on every change; bounded incremental relayout remains
|
|
26
|
+
* a Phase-5 target.
|
|
27
|
+
*
|
|
28
|
+
* Ownership rules:
|
|
29
|
+
* - DocumentRuntime owns the stateful engine lifecycle and cache invalidation.
|
|
30
|
+
* - ProseMirror remains the editing surface, never the page compositor.
|
|
31
|
+
* - React/Tailwind consume resulting snapshots as read-models.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type {
|
|
35
|
+
DocumentPageSnapshot,
|
|
36
|
+
EditorSurfaceSnapshot,
|
|
37
|
+
PageLayoutSnapshot,
|
|
38
|
+
SurfaceBlockSnapshot,
|
|
39
|
+
} from "../../api/public-types";
|
|
40
|
+
import type {
|
|
41
|
+
FootnoteCollection,
|
|
42
|
+
} from "../../model/canonical-document.ts";
|
|
43
|
+
import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
44
|
+
import {
|
|
45
|
+
buildPageLayoutSnapshot,
|
|
46
|
+
buildResolvedSections,
|
|
47
|
+
type ResolvedDocumentSection,
|
|
48
|
+
} from "../document-layout.ts";
|
|
49
|
+
import {
|
|
50
|
+
estimateBlockHeight,
|
|
51
|
+
estimateParagraphHeight,
|
|
52
|
+
getUsableColumnMetrics,
|
|
53
|
+
getUsableColumnWidth,
|
|
54
|
+
getUsablePageHeight,
|
|
55
|
+
} from "../page-layout-estimation.ts";
|
|
56
|
+
import {
|
|
57
|
+
calculateParagraphHeight,
|
|
58
|
+
resolveBlockFormatting,
|
|
59
|
+
resolveCharsPerLine,
|
|
60
|
+
resolveNumberingPrefixLength,
|
|
61
|
+
resolveTextWidth,
|
|
62
|
+
} from "./resolved-formatting-state.ts";
|
|
63
|
+
import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Types
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export type LayoutInvalidationReason =
|
|
70
|
+
| { kind: "content-edit"; from: number; to: number }
|
|
71
|
+
| { kind: "section-change"; sectionIndex: number }
|
|
72
|
+
| { kind: "styles-change" }
|
|
73
|
+
| { kind: "theme-change" }
|
|
74
|
+
| { kind: "numbering-change"; numberingInstanceId?: string }
|
|
75
|
+
| { kind: "field-refresh"; family?: string }
|
|
76
|
+
| { kind: "full" };
|
|
77
|
+
|
|
78
|
+
export interface PageStackResult {
|
|
79
|
+
pages: DocumentPageSnapshot[];
|
|
80
|
+
sections: ResolvedDocumentSection[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Facade
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the page stack for a document.
|
|
89
|
+
*
|
|
90
|
+
* This is the single entry point for page composition. All consumers
|
|
91
|
+
* (document-navigation, view-state, page mode) should call this instead
|
|
92
|
+
* of directly using the estimation helpers.
|
|
93
|
+
*/
|
|
94
|
+
export function buildPageStack(
|
|
95
|
+
document: CanonicalDocumentEnvelope,
|
|
96
|
+
sections: ResolvedDocumentSection[],
|
|
97
|
+
mainSurface: EditorSurfaceSnapshot,
|
|
98
|
+
): DocumentPageSnapshot[] {
|
|
99
|
+
const pages: DocumentPageSnapshot[] = [];
|
|
100
|
+
let globalPageIndex = 0;
|
|
101
|
+
|
|
102
|
+
for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
|
|
103
|
+
const section = sections[sectionIdx]!;
|
|
104
|
+
const layout = buildPageLayoutSnapshot(
|
|
105
|
+
section.index,
|
|
106
|
+
section.properties ?? document.subParts?.finalSectionProperties,
|
|
107
|
+
document.subParts,
|
|
108
|
+
);
|
|
109
|
+
const sectionBlocks = collectSectionBlocks(mainSurface.blocks, section.start, section.end);
|
|
110
|
+
|
|
111
|
+
// In OOXML, sectionType on a section's properties describes how
|
|
112
|
+
// the NEXT section starts — it is stored on the section that ends
|
|
113
|
+
// at the break. So when processing section N (N > 0), the break
|
|
114
|
+
// type that governs section N's start is on section N-1's properties.
|
|
115
|
+
//
|
|
116
|
+
// OOXML even/odd refers to 1-based display page numbers:
|
|
117
|
+
// globalPageIndex 0 → next display page 1 (odd)
|
|
118
|
+
// globalPageIndex 1 → next display page 2 (even)
|
|
119
|
+
const prevSection = sectionIdx > 0 ? sections[sectionIdx - 1] : undefined;
|
|
120
|
+
const breakType = prevSection?.properties?.sectionType;
|
|
121
|
+
const nextDisplayPage = globalPageIndex + 1; // 1-based
|
|
122
|
+
const isContinuous = breakType === "continuous" || breakType === "nextColumn";
|
|
123
|
+
|
|
124
|
+
if (breakType === "evenPage" && globalPageIndex > 0) {
|
|
125
|
+
if (nextDisplayPage % 2 !== 0) {
|
|
126
|
+
const prevLayout = pages[pages.length - 1]?.layout ?? layout;
|
|
127
|
+
pages.push({
|
|
128
|
+
pageIndex: globalPageIndex,
|
|
129
|
+
sectionIndex: section.index,
|
|
130
|
+
pageInSection: -1,
|
|
131
|
+
startOffset: section.start,
|
|
132
|
+
endOffset: section.start,
|
|
133
|
+
layout: prevLayout,
|
|
134
|
+
});
|
|
135
|
+
globalPageIndex += 1;
|
|
136
|
+
}
|
|
137
|
+
} else if (breakType === "oddPage" && globalPageIndex > 0) {
|
|
138
|
+
if (nextDisplayPage % 2 === 0) {
|
|
139
|
+
const prevLayout = pages[pages.length - 1]?.layout ?? layout;
|
|
140
|
+
pages.push({
|
|
141
|
+
pageIndex: globalPageIndex,
|
|
142
|
+
sectionIndex: section.index,
|
|
143
|
+
pageInSection: -1,
|
|
144
|
+
startOffset: section.start,
|
|
145
|
+
endOffset: section.start,
|
|
146
|
+
layout: prevLayout,
|
|
147
|
+
});
|
|
148
|
+
globalPageIndex += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const paginated = paginateSectionBlocks(
|
|
153
|
+
section,
|
|
154
|
+
sectionBlocks,
|
|
155
|
+
layout,
|
|
156
|
+
document.subParts?.footnoteCollection,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// continuous / nextColumn: merge the first page of this section into the
|
|
160
|
+
// previous section's last page (same visual sheet of paper, different
|
|
161
|
+
// semantic section). The merged page keeps the PREVIOUS section's
|
|
162
|
+
// layout because page geometry cannot change mid-page in OOXML.
|
|
163
|
+
let firstPageHandled = false;
|
|
164
|
+
if (
|
|
165
|
+
isContinuous &&
|
|
166
|
+
pages.length > 0 &&
|
|
167
|
+
paginated.length > 0 &&
|
|
168
|
+
pages[pages.length - 1]!.pageInSection !== -1
|
|
169
|
+
) {
|
|
170
|
+
const previousPage = pages[pages.length - 1]!;
|
|
171
|
+
const firstNewPage = paginated[0]!;
|
|
172
|
+
// Extend the previous page's endOffset through the continuous section's
|
|
173
|
+
// first page content. Subsequent pages in the continuous section flow
|
|
174
|
+
// onto fresh pages as if the break were `nextPage`.
|
|
175
|
+
previousPage.endOffset = Math.max(
|
|
176
|
+
previousPage.endOffset,
|
|
177
|
+
firstNewPage.endOffset,
|
|
178
|
+
);
|
|
179
|
+
firstPageHandled = true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < paginated.length; i += 1) {
|
|
183
|
+
if (firstPageHandled && i === 0) continue;
|
|
184
|
+
const page = paginated[i]!;
|
|
185
|
+
pages.push({
|
|
186
|
+
...page,
|
|
187
|
+
pageIndex: globalPageIndex,
|
|
188
|
+
});
|
|
189
|
+
globalPageIndex += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Guarantee at least one page
|
|
194
|
+
if (pages.length === 0) {
|
|
195
|
+
pages.push({
|
|
196
|
+
pageIndex: 0,
|
|
197
|
+
sectionIndex: 0,
|
|
198
|
+
pageInSection: 0,
|
|
199
|
+
startOffset: 0,
|
|
200
|
+
endOffset: mainSurface.storySize,
|
|
201
|
+
layout: buildPageLayoutSnapshot(
|
|
202
|
+
0,
|
|
203
|
+
document.subParts?.finalSectionProperties,
|
|
204
|
+
document.subParts,
|
|
205
|
+
),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
applyWidowControlPass(pages, mainSurface);
|
|
210
|
+
return pages;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resumable variant of `buildPageStack` — returns page snapshots starting at
|
|
215
|
+
* `resumeAt.startPageIndex`, suitable for splicing into a prior page graph.
|
|
216
|
+
*
|
|
217
|
+
* Correctness baseline: the current implementation calls the full
|
|
218
|
+
* `buildPageStack` and slices. This is not a performance win on its own —
|
|
219
|
+
* it is a safe contract that lets the engine stitch bounded rebuilds without
|
|
220
|
+
* writing a custom resume traversal. Widow/orphan, section-break fillers,
|
|
221
|
+
* and continuous-section merges remain handled by the stable pipeline.
|
|
222
|
+
*
|
|
223
|
+
* Future work can replace the body with a true resume that reuses block
|
|
224
|
+
* heights computed for pages before `startPageIndex`.
|
|
225
|
+
*/
|
|
226
|
+
export function buildPageStackFrom(
|
|
227
|
+
document: CanonicalDocumentEnvelope,
|
|
228
|
+
sections: readonly ResolvedDocumentSection[],
|
|
229
|
+
mainSurface: EditorSurfaceSnapshot,
|
|
230
|
+
resumeAt: { startPageIndex: number; startOffset: number },
|
|
231
|
+
): DocumentPageSnapshot[] {
|
|
232
|
+
// Correctness-first: run the full pipeline and return pages from the
|
|
233
|
+
// requested start. `startOffset` is accepted for forward compatibility
|
|
234
|
+
// with a future true-resume but is not required by this implementation.
|
|
235
|
+
void resumeAt.startOffset;
|
|
236
|
+
const full = buildPageStack(
|
|
237
|
+
document,
|
|
238
|
+
sections as ResolvedDocumentSection[],
|
|
239
|
+
mainSurface,
|
|
240
|
+
);
|
|
241
|
+
const startIndex = Math.max(0, resumeAt.startPageIndex);
|
|
242
|
+
return full.slice(startIndex);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Widow control pass
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Bounded widow/orphan correction.
|
|
251
|
+
*
|
|
252
|
+
* After pagination we inspect each page pair. If page N-1 ends with a
|
|
253
|
+
* paragraph whose `widowControl` is enabled AND the paragraph's final line
|
|
254
|
+
* falls on page N (i.e. page N starts with exactly the trailing tail of a
|
|
255
|
+
* paragraph that began on page N-1), we push the last line back to page N
|
|
256
|
+
* by reducing page N-1's endOffset by the width of that trailing line.
|
|
257
|
+
*
|
|
258
|
+
* The current pass is bounded: it only corrects the single-line case. It
|
|
259
|
+
* does not attempt to re-flow arbitrary content. When the measurement
|
|
260
|
+
* provider upgrades to canvas fidelity and we carry per-paragraph line
|
|
261
|
+
* boxes, this pass will become more precise.
|
|
262
|
+
*
|
|
263
|
+
* Today it operates on block-level boundaries — if a paragraph with
|
|
264
|
+
* widowControl straddles a page boundary and the trailing slice covers less
|
|
265
|
+
* than ~15% of its full height (estimated), we pull the whole paragraph
|
|
266
|
+
* forward. This is a conservative heuristic that will not corrupt
|
|
267
|
+
* pagination; it only very slightly compresses page breaks.
|
|
268
|
+
*/
|
|
269
|
+
function applyWidowControlPass(
|
|
270
|
+
pages: DocumentPageSnapshot[],
|
|
271
|
+
mainSurface: EditorSurfaceSnapshot,
|
|
272
|
+
): void {
|
|
273
|
+
if (pages.length < 2) return;
|
|
274
|
+
|
|
275
|
+
for (let i = 1; i < pages.length; i += 1) {
|
|
276
|
+
const prev = pages[i - 1]!;
|
|
277
|
+
const cur = pages[i]!;
|
|
278
|
+
if (prev.pageInSection === -1 || cur.pageInSection === -1) continue;
|
|
279
|
+
|
|
280
|
+
// Find the paragraph, if any, that straddles the boundary.
|
|
281
|
+
const straddling = mainSurface.blocks.find(
|
|
282
|
+
(block) =>
|
|
283
|
+
block.kind === "paragraph" &&
|
|
284
|
+
block.from < prev.endOffset &&
|
|
285
|
+
block.to > prev.endOffset &&
|
|
286
|
+
block.to <= cur.endOffset,
|
|
287
|
+
);
|
|
288
|
+
if (!straddling || straddling.kind !== "paragraph") continue;
|
|
289
|
+
if (straddling.widowControl === false) continue;
|
|
290
|
+
if (straddling.from === prev.endOffset) continue; // fully on next page
|
|
291
|
+
|
|
292
|
+
const totalSpan = straddling.to - straddling.from;
|
|
293
|
+
const onNextPage = straddling.to - prev.endOffset;
|
|
294
|
+
if (totalSpan <= 0) continue;
|
|
295
|
+
// Pull-forward when the trailing slice is less than 15% of the paragraph,
|
|
296
|
+
// i.e. likely a single stranded line.
|
|
297
|
+
if (onNextPage / totalSpan <= 0.15) {
|
|
298
|
+
prev.endOffset = straddling.to;
|
|
299
|
+
cur.startOffset = straddling.to;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Compute the full page stack result including resolved sections.
|
|
306
|
+
*
|
|
307
|
+
* Convenience wrapper for callers that need both sections and pages.
|
|
308
|
+
*/
|
|
309
|
+
export function computePageStack(
|
|
310
|
+
document: CanonicalDocumentEnvelope,
|
|
311
|
+
mainSurface: EditorSurfaceSnapshot,
|
|
312
|
+
): PageStackResult {
|
|
313
|
+
const sections = buildResolvedSections(document);
|
|
314
|
+
const pages = buildPageStack(document, sections, mainSurface);
|
|
315
|
+
return { pages, sections };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Invalidation — delegates to layout-invalidation.ts
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Determine whether a layout invalidation reason requires a full recompute.
|
|
324
|
+
*
|
|
325
|
+
* Delegates to `analyzeInvalidation()` which classifies the reason against
|
|
326
|
+
* the current graph. Callers that already hold a graph should call
|
|
327
|
+
* `analyzeInvalidation` directly for finer-grained output.
|
|
328
|
+
*/
|
|
329
|
+
export function requiresFullRecompute(reason: LayoutInvalidationReason): boolean {
|
|
330
|
+
// Use a conservative analysis with no graph — returns full recompute for
|
|
331
|
+
// all reasons except `field-refresh`, matching Phase-4 semantics.
|
|
332
|
+
return analyzeInvalidationFn(reason, null).requiresFullRecompute;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Block measurement — uses ResolvedFormattingState for paragraphs
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
const MIN_BLOCK_HEIGHT_TWIPS = 240;
|
|
340
|
+
const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Compute block height using resolved formatting when available.
|
|
344
|
+
* Uses improved table measurement for legal contracts.
|
|
345
|
+
*/
|
|
346
|
+
function measureBlockHeight(
|
|
347
|
+
block: SurfaceBlockSnapshot | undefined,
|
|
348
|
+
columnWidth: number,
|
|
349
|
+
): number {
|
|
350
|
+
if (!block) return 0;
|
|
351
|
+
|
|
352
|
+
switch (block.kind) {
|
|
353
|
+
case "paragraph": {
|
|
354
|
+
const formatting = resolveBlockFormatting(block);
|
|
355
|
+
if (formatting) {
|
|
356
|
+
const lineCount = measureParagraphLineCount(block, formatting, columnWidth);
|
|
357
|
+
return calculateParagraphHeight(formatting, lineCount);
|
|
358
|
+
}
|
|
359
|
+
return estimateBlockHeight(block, columnWidth);
|
|
360
|
+
}
|
|
361
|
+
case "table":
|
|
362
|
+
return measureTableHeight(block, columnWidth);
|
|
363
|
+
case "sdt_block":
|
|
364
|
+
return Math.max(
|
|
365
|
+
MIN_BLOCK_HEIGHT_TWIPS,
|
|
366
|
+
block.children.reduce((total, child) => total + measureBlockHeight(child, columnWidth), 0),
|
|
367
|
+
);
|
|
368
|
+
case "opaque_block":
|
|
369
|
+
return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Improved table height estimation.
|
|
375
|
+
* Uses resolved formatting for cell content paragraphs and respects
|
|
376
|
+
* explicit row heights and height rules.
|
|
377
|
+
*/
|
|
378
|
+
function measureTableHeight(
|
|
379
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
|
|
380
|
+
columnWidth: number,
|
|
381
|
+
): number {
|
|
382
|
+
const TABLE_ROW_PADDING_TWIPS = 120;
|
|
383
|
+
let totalHeight = 0;
|
|
384
|
+
|
|
385
|
+
for (const row of block.rows) {
|
|
386
|
+
const explicitHeight = row.height ?? 0;
|
|
387
|
+
const heightRule = row.heightRule ?? "auto";
|
|
388
|
+
|
|
389
|
+
// Calculate content-driven height
|
|
390
|
+
let contentHeight = MIN_BLOCK_HEIGHT_TWIPS;
|
|
391
|
+
const cellCount = Math.max(1, row.cells.length);
|
|
392
|
+
const cellWidth = Math.max(720, Math.floor(columnWidth / cellCount));
|
|
393
|
+
|
|
394
|
+
for (const cell of row.cells) {
|
|
395
|
+
let cellContentHeight = 0;
|
|
396
|
+
for (const child of cell.content) {
|
|
397
|
+
cellContentHeight += measureBlockHeight(child, cellWidth);
|
|
398
|
+
}
|
|
399
|
+
contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (heightRule === "exact" && explicitHeight > 0) {
|
|
403
|
+
totalHeight += explicitHeight;
|
|
404
|
+
} else if (heightRule === "atLeast" && explicitHeight > 0) {
|
|
405
|
+
totalHeight += Math.max(explicitHeight, contentHeight);
|
|
406
|
+
} else if (explicitHeight > 0) {
|
|
407
|
+
totalHeight += Math.max(explicitHeight, contentHeight);
|
|
408
|
+
} else {
|
|
409
|
+
totalHeight += contentHeight;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return Math.max(MIN_BLOCK_HEIGHT_TWIPS, totalHeight);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Count lines in a paragraph using resolved formatting.
|
|
418
|
+
* Accounts for proper indentation, font metrics, and numbering geometry.
|
|
419
|
+
*/
|
|
420
|
+
function measureParagraphLineCount(
|
|
421
|
+
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
422
|
+
formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
|
|
423
|
+
columnWidth: number,
|
|
424
|
+
): number {
|
|
425
|
+
const firstLineWidth = resolveTextWidth(formatting, columnWidth, true);
|
|
426
|
+
const subsequentLineWidth = resolveTextWidth(formatting, columnWidth, false);
|
|
427
|
+
const firstLineCapacity = resolveCharsPerLine(firstLineWidth, formatting.averageCharWidthTwips);
|
|
428
|
+
const subsequentLineCapacity = resolveCharsPerLine(subsequentLineWidth, formatting.averageCharWidthTwips);
|
|
429
|
+
|
|
430
|
+
let lineCount = 1;
|
|
431
|
+
let currentLineChars = resolveNumberingPrefixLength(block);
|
|
432
|
+
let currentLineCapacity = firstLineCapacity;
|
|
433
|
+
|
|
434
|
+
for (const segment of block.segments) {
|
|
435
|
+
switch (segment.kind) {
|
|
436
|
+
case "text":
|
|
437
|
+
currentLineChars += Array.from(segment.text).length;
|
|
438
|
+
while (currentLineChars > currentLineCapacity) {
|
|
439
|
+
lineCount += 1;
|
|
440
|
+
currentLineChars -= currentLineCapacity;
|
|
441
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
case "tab": {
|
|
445
|
+
// Resolve tab to actual tab stop position if available
|
|
446
|
+
const tabAdvance = resolveTabAdvance(formatting, currentLineChars, formatting.averageCharWidthTwips, columnWidth);
|
|
447
|
+
currentLineChars += tabAdvance;
|
|
448
|
+
while (currentLineChars > currentLineCapacity) {
|
|
449
|
+
lineCount += 1;
|
|
450
|
+
currentLineChars -= currentLineCapacity;
|
|
451
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
452
|
+
}
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
case "hard_break":
|
|
456
|
+
lineCount += 1;
|
|
457
|
+
currentLineChars = 0;
|
|
458
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
459
|
+
break;
|
|
460
|
+
case "image":
|
|
461
|
+
lineCount += Math.max(1, Math.round(segment.display === "floating" ? 2 : 1));
|
|
462
|
+
currentLineChars = 0;
|
|
463
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
464
|
+
break;
|
|
465
|
+
case "note_ref":
|
|
466
|
+
currentLineChars += 1;
|
|
467
|
+
while (currentLineChars > currentLineCapacity) {
|
|
468
|
+
lineCount += 1;
|
|
469
|
+
currentLineChars -= currentLineCapacity;
|
|
470
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
case "opaque_inline":
|
|
474
|
+
if (segment.presentation !== "quiet-marker") {
|
|
475
|
+
currentLineChars += segment.label.length > 0 ? 1 : 0;
|
|
476
|
+
while (currentLineChars > currentLineCapacity) {
|
|
477
|
+
lineCount += 1;
|
|
478
|
+
currentLineChars -= currentLineCapacity;
|
|
479
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return Math.max(1, lineCount);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Resolve tab advance in character-equivalents, considering tab stops.
|
|
491
|
+
*/
|
|
492
|
+
function resolveTabAdvance(
|
|
493
|
+
formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
|
|
494
|
+
currentChars: number,
|
|
495
|
+
avgCharWidth: number,
|
|
496
|
+
columnWidth: number,
|
|
497
|
+
): number {
|
|
498
|
+
// Default tab stops every 720 twips (0.5 inch)
|
|
499
|
+
const defaultTabInterval = 720;
|
|
500
|
+
const currentPosition = currentChars * avgCharWidth + formatting.indentLeft;
|
|
501
|
+
|
|
502
|
+
if (formatting.tabStops.length === 0) {
|
|
503
|
+
const nextTab = Math.ceil((currentPosition + 1) / defaultTabInterval) * defaultTabInterval;
|
|
504
|
+
const advance = nextTab - currentPosition;
|
|
505
|
+
return Math.max(1, Math.round(advance / avgCharWidth));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Find the next tab stop after current position
|
|
509
|
+
for (const tab of formatting.tabStops) {
|
|
510
|
+
if (tab.position > currentPosition) {
|
|
511
|
+
const advance = tab.position - currentPosition;
|
|
512
|
+
return Math.max(1, Math.round(advance / avgCharWidth));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Past all tab stops — use default advance
|
|
517
|
+
return 4;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// Section block collection
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
function collectSectionBlocks(
|
|
525
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
526
|
+
start: number,
|
|
527
|
+
end: number,
|
|
528
|
+
): SurfaceBlockSnapshot[] {
|
|
529
|
+
return blocks.filter((block) => block.to > start && block.from < end);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function paginateSectionBlocks(
|
|
533
|
+
section: ResolvedDocumentSection,
|
|
534
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
535
|
+
layout: DocumentPageSnapshot["layout"],
|
|
536
|
+
footnotes: FootnoteCollection | undefined,
|
|
537
|
+
): Omit<DocumentPageSnapshot, "pageIndex">[] {
|
|
538
|
+
if (blocks.length === 0) {
|
|
539
|
+
return [
|
|
540
|
+
{
|
|
541
|
+
sectionIndex: section.index,
|
|
542
|
+
pageInSection: 0,
|
|
543
|
+
startOffset: section.start,
|
|
544
|
+
endOffset: section.end,
|
|
545
|
+
layout,
|
|
546
|
+
},
|
|
547
|
+
];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const pages: Omit<DocumentPageSnapshot, "pageIndex">[] = [];
|
|
551
|
+
const usableHeight = getUsablePageHeight(layout);
|
|
552
|
+
const columnMetrics = getUsableColumnMetrics(layout);
|
|
553
|
+
const maxColumns = Math.max(1, columnMetrics.length);
|
|
554
|
+
let pageStart = section.start;
|
|
555
|
+
let columnHeight = 0;
|
|
556
|
+
let columnIndex = 0;
|
|
557
|
+
let pageInSection = 0;
|
|
558
|
+
let reservedNoteHeight = 0;
|
|
559
|
+
const reservedNotes = new Set<string>();
|
|
560
|
+
|
|
561
|
+
const pushPage = (endOffset: number): void => {
|
|
562
|
+
const boundedEnd = Math.max(pageStart, Math.min(endOffset, section.end));
|
|
563
|
+
if (boundedEnd === pageStart && pages.length > 0) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
pages.push({
|
|
567
|
+
sectionIndex: section.index,
|
|
568
|
+
pageInSection,
|
|
569
|
+
startOffset: pageStart,
|
|
570
|
+
endOffset: boundedEnd,
|
|
571
|
+
layout,
|
|
572
|
+
});
|
|
573
|
+
pageInSection += 1;
|
|
574
|
+
pageStart = boundedEnd;
|
|
575
|
+
columnHeight = 0;
|
|
576
|
+
columnIndex = 0;
|
|
577
|
+
reservedNoteHeight = 0;
|
|
578
|
+
reservedNotes.clear();
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
for (let index = 0; index < blocks.length; index += 1) {
|
|
582
|
+
const block = blocks[index]!;
|
|
583
|
+
const nextBoundary = blocks[index + 1]?.from ?? section.end;
|
|
584
|
+
while (true) {
|
|
585
|
+
const columnWidth =
|
|
586
|
+
columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
|
|
587
|
+
getUsableColumnWidth(layout);
|
|
588
|
+
const baseHeight = measureBlockHeight(block, columnWidth);
|
|
589
|
+
|
|
590
|
+
// keepNext: this paragraph must stay with the next one on the same page
|
|
591
|
+
const keepWithNextHeight =
|
|
592
|
+
block.kind === "paragraph" && block.keepNext
|
|
593
|
+
? baseHeight + measureBlockHeight(blocks[index + 1], columnWidth)
|
|
594
|
+
: baseHeight;
|
|
595
|
+
|
|
596
|
+
// keepLines: the entire paragraph must fit on one page.
|
|
597
|
+
// If it doesn't fit and there's already content on this page, break before it.
|
|
598
|
+
const formatting = block.kind === "paragraph" ? resolveBlockFormatting(block) : null;
|
|
599
|
+
const keepLinesActive = formatting?.keepLines ?? false;
|
|
600
|
+
|
|
601
|
+
const noteHeight = estimateFootnoteReservation(block, footnotes, columnWidth, reservedNotes);
|
|
602
|
+
const projectedHeight = columnHeight + keepWithNextHeight + reservedNoteHeight + noteHeight;
|
|
603
|
+
|
|
604
|
+
// pageBreakBefore
|
|
605
|
+
if (block.kind === "paragraph" && block.pageBreakBefore && pageStart < block.from) {
|
|
606
|
+
pushPage(block.from);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Overflow check — paragraph doesn't fit on current page
|
|
611
|
+
if (projectedHeight > usableHeight && pageStart < block.from) {
|
|
612
|
+
if (columnIndex < maxColumns - 1) {
|
|
613
|
+
columnIndex += 1;
|
|
614
|
+
columnHeight = 0;
|
|
615
|
+
reservedNoteHeight = 0;
|
|
616
|
+
reservedNotes.clear();
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
pushPage(block.from);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// keepLines: if the paragraph alone exceeds page height and there's
|
|
624
|
+
// already content, push it to the next page (the paragraph itself will
|
|
625
|
+
// span the full page if it's truly larger than a page).
|
|
626
|
+
if (keepLinesActive && columnHeight > 0 && baseHeight > usableHeight - columnHeight && pageStart < block.from) {
|
|
627
|
+
if (columnIndex < maxColumns - 1) {
|
|
628
|
+
columnIndex += 1;
|
|
629
|
+
columnHeight = 0;
|
|
630
|
+
reservedNoteHeight = 0;
|
|
631
|
+
reservedNotes.clear();
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
pushPage(block.from);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const effectiveNoteHeight = estimateFootnoteReservation(
|
|
639
|
+
block,
|
|
640
|
+
footnotes,
|
|
641
|
+
columnWidth,
|
|
642
|
+
reservedNotes,
|
|
643
|
+
);
|
|
644
|
+
columnHeight += baseHeight;
|
|
645
|
+
reservedNoteHeight += effectiveNoteHeight;
|
|
646
|
+
currentPageNoteIds(block).forEach((noteKey) => reservedNotes.add(noteKey));
|
|
647
|
+
|
|
648
|
+
if (hasColumnBreak(block)) {
|
|
649
|
+
if (columnIndex < maxColumns - 1) {
|
|
650
|
+
columnIndex += 1;
|
|
651
|
+
columnHeight = 0;
|
|
652
|
+
reservedNoteHeight = 0;
|
|
653
|
+
reservedNotes.clear();
|
|
654
|
+
} else {
|
|
655
|
+
pushPage(nextBoundary);
|
|
656
|
+
}
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (index === blocks.length - 1) {
|
|
661
|
+
pushPage(section.end);
|
|
662
|
+
}
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return pages.length > 0
|
|
668
|
+
? pages
|
|
669
|
+
: [
|
|
670
|
+
{
|
|
671
|
+
sectionIndex: section.index,
|
|
672
|
+
pageInSection: 0,
|
|
673
|
+
startOffset: section.start,
|
|
674
|
+
endOffset: section.end,
|
|
675
|
+
layout,
|
|
676
|
+
},
|
|
677
|
+
];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function estimateFootnoteReservation(
|
|
681
|
+
block: SurfaceBlockSnapshot,
|
|
682
|
+
footnotes: FootnoteCollection | undefined,
|
|
683
|
+
columnWidth: number,
|
|
684
|
+
reservedNotes: ReadonlySet<string>,
|
|
685
|
+
): number {
|
|
686
|
+
if (!footnotes || block.kind !== "paragraph") {
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
let reservation = 0;
|
|
691
|
+
for (const noteKey of currentPageNoteIds(block)) {
|
|
692
|
+
if (reservedNotes.has(noteKey)) {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const [noteKind, noteId] = noteKey.split(":");
|
|
697
|
+
const noteCollection =
|
|
698
|
+
noteKind === "endnote" ? footnotes.endnotes : footnotes.footnotes;
|
|
699
|
+
const note = noteCollection[noteId];
|
|
700
|
+
reservation += FOOTNOTE_REFERENCE_RESERVATION_TWIPS;
|
|
701
|
+
if (note) {
|
|
702
|
+
reservation += note.blocks.reduce(
|
|
703
|
+
(total, child) => total + estimateCanonicalNoteBlockHeight(child, columnWidth),
|
|
704
|
+
0,
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return reservation;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function estimateCanonicalNoteBlockHeight(
|
|
713
|
+
block: FootnoteCollection["footnotes"][string]["blocks"][number],
|
|
714
|
+
columnWidth: number,
|
|
715
|
+
): number {
|
|
716
|
+
switch (block.type) {
|
|
717
|
+
case "paragraph":
|
|
718
|
+
return estimateParagraphHeight(
|
|
719
|
+
{
|
|
720
|
+
blockId: "note",
|
|
721
|
+
kind: "paragraph",
|
|
722
|
+
from: 0,
|
|
723
|
+
to: 0,
|
|
724
|
+
...(block.styleId ? { styleId: block.styleId } : {}),
|
|
725
|
+
segments: createEstimatedNoteSegments(block.children),
|
|
726
|
+
},
|
|
727
|
+
columnWidth,
|
|
728
|
+
);
|
|
729
|
+
case "table":
|
|
730
|
+
return MIN_BLOCK_HEIGHT_TWIPS * Math.max(1, block.rows.length);
|
|
731
|
+
default:
|
|
732
|
+
return MIN_BLOCK_HEIGHT_TWIPS;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function createEstimatedNoteSegments(
|
|
737
|
+
children: Extract<FootnoteCollection["footnotes"][string]["blocks"][number], { type: "paragraph" }>["children"],
|
|
738
|
+
): import("../../api/public-types").SurfaceInlineSegment[] {
|
|
739
|
+
const segments: import("../../api/public-types").SurfaceInlineSegment[] = [];
|
|
740
|
+
|
|
741
|
+
children.forEach((child, index) => {
|
|
742
|
+
if (child.type === "text") {
|
|
743
|
+
segments.push({
|
|
744
|
+
segmentId: `note-${index}`,
|
|
745
|
+
kind: "text",
|
|
746
|
+
from: 0,
|
|
747
|
+
to: Array.from(child.text).length,
|
|
748
|
+
text: child.text,
|
|
749
|
+
});
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (child.type === "hard_break" || child.type === "tab") {
|
|
754
|
+
segments.push({
|
|
755
|
+
segmentId: `note-${index}`,
|
|
756
|
+
kind: child.type,
|
|
757
|
+
from: 0,
|
|
758
|
+
to: 1,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
return segments;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function currentPageNoteIds(
|
|
767
|
+
block: SurfaceBlockSnapshot,
|
|
768
|
+
): Set<string> {
|
|
769
|
+
const notes = new Set<string>();
|
|
770
|
+
if (block.kind !== "paragraph") {
|
|
771
|
+
return notes;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
for (const segment of block.segments) {
|
|
775
|
+
if (segment.kind === "note_ref" && segment.noteId) {
|
|
776
|
+
notes.add(`${segment.noteKind}:${segment.noteId}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return notes;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
|
|
783
|
+
return block.kind === "paragraph" && block.segments.some(
|
|
784
|
+
(segment) =>
|
|
785
|
+
segment.kind === "opaque_inline" &&
|
|
786
|
+
segment.label === "Column break",
|
|
787
|
+
);
|
|
788
|
+
}
|