@beyondwork/docx-react-component 1.0.37 → 1.0.39
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 +41 -31
- package/src/api/public-types.ts +496 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +845 -56
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-main-document.ts +2 -11
- package/src/io/export/serialize-numbering.ts +43 -10
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/export/serialize-tables.ts +74 -0
- package/src/io/export/table-properties-xml.ts +139 -4
- package/src/io/normalize/normalize-text.ts +15 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-footnotes.ts +60 -0
- package/src/io/ooxml/parse-headers-footers.ts +60 -0
- package/src/io/ooxml/parse-main-document.ts +137 -0
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +117 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +1 -1
- package/src/runtime/document-runtime.ts +248 -18
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/index.ts +47 -0
- package/src/runtime/layout/inert-layout-facet.ts +16 -0
- package/src/runtime/layout/layout-engine-instance.ts +100 -23
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-graph.ts +55 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +484 -37
- package/src/runtime/layout/project-block-fragments.ts +225 -0
- package/src/runtime/layout/public-facet.ts +748 -16
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +249 -0
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +759 -0
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +368 -19
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +310 -15
- package/src/ui/headless/scoped-chrome-policy.ts +49 -1
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +29 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +498 -163
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
- package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -61,6 +61,8 @@ import {
|
|
|
61
61
|
resolveTextWidth,
|
|
62
62
|
} from "./resolved-formatting-state.ts";
|
|
63
63
|
import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
|
|
64
|
+
import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
|
|
65
|
+
import { paginateParagraphLines } from "./paginate-paragraph-lines.ts";
|
|
64
66
|
|
|
65
67
|
// ---------------------------------------------------------------------------
|
|
66
68
|
// Types
|
|
@@ -80,6 +82,58 @@ export interface PageStackResult {
|
|
|
80
82
|
sections: ResolvedDocumentSection[];
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One slice of a paragraph split across a page boundary.
|
|
87
|
+
*
|
|
88
|
+
* Emitted when intra-paragraph line-box pagination produces a block that
|
|
89
|
+
* renders on multiple pages. The offset range on the page snapshot still
|
|
90
|
+
* spans the whole paragraph (pages are contiguous, non-overlapping); the
|
|
91
|
+
* slice tells consumers which lines of the paragraph render on which page.
|
|
92
|
+
*/
|
|
93
|
+
export interface ParagraphLineSlice {
|
|
94
|
+
/** Global (whole-document) pageIndex this slice renders on. */
|
|
95
|
+
pageIndex: number;
|
|
96
|
+
/** Inclusive-exclusive line range rendered by this slice. */
|
|
97
|
+
lineRange: { from: number; to: number; totalLines: number };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* R3: one slice of a table split at a row boundary across a page break.
|
|
102
|
+
*
|
|
103
|
+
* Emitted when a table's total measured height exceeds the remaining page
|
|
104
|
+
* space and pagination commits to row-level splitting. Consumers render
|
|
105
|
+
* the rows in `rowRange` on the indicated `pageIndex`; when `rowRange.from > 0`
|
|
106
|
+
* and the source table has rows with `isHeader: true`, consumers are
|
|
107
|
+
* expected to prepend those header rows visually so the table header
|
|
108
|
+
* repeats on every continuation page.
|
|
109
|
+
*/
|
|
110
|
+
export interface TableRowSlice {
|
|
111
|
+
/** Global (whole-document) pageIndex this slice renders on. */
|
|
112
|
+
pageIndex: number;
|
|
113
|
+
/** Inclusive-exclusive row range rendered by this slice. */
|
|
114
|
+
rowRange: { from: number; to: number; totalRows: number };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Per-block slice information produced by pagination. `byBlockId` is keyed
|
|
119
|
+
* by `SurfaceBlockSnapshot.blockId`. Absence of a key means the block is
|
|
120
|
+
* unsplit and renders as a single fragment on exactly one page.
|
|
121
|
+
*
|
|
122
|
+
* Paragraph-slice and table-slice keys are disjoint (a block is either a
|
|
123
|
+
* paragraph or a table, never both). Consumers read the appropriate map
|
|
124
|
+
* for the block kind they're projecting.
|
|
125
|
+
*/
|
|
126
|
+
export interface BlockSplits {
|
|
127
|
+
byBlockId: Map<string, ParagraphLineSlice[]>;
|
|
128
|
+
/** R3: table blocks split at row boundaries. */
|
|
129
|
+
tablesByBlockId: Map<string, TableRowSlice[]>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface PageStackResultWithSplits {
|
|
133
|
+
pages: DocumentPageSnapshot[];
|
|
134
|
+
splits: BlockSplits;
|
|
135
|
+
}
|
|
136
|
+
|
|
83
137
|
// ---------------------------------------------------------------------------
|
|
84
138
|
// Facade
|
|
85
139
|
// ---------------------------------------------------------------------------
|
|
@@ -90,13 +144,38 @@ export interface PageStackResult {
|
|
|
90
144
|
* This is the single entry point for page composition. All consumers
|
|
91
145
|
* (document-navigation, view-state, page mode) should call this instead
|
|
92
146
|
* of directly using the estimation helpers.
|
|
147
|
+
*
|
|
148
|
+
* `measurementProvider` is optional. When supplied, paragraph line counts
|
|
149
|
+
* and block heights consult the provider's `measureLineFragments` so the
|
|
150
|
+
* canvas backend can participate. When omitted, measurement falls through
|
|
151
|
+
* to the empirical path baked into this module — which matches the
|
|
152
|
+
* provider's empirical backend numerically, keeping pagination stable.
|
|
93
153
|
*/
|
|
94
154
|
export function buildPageStack(
|
|
95
155
|
document: CanonicalDocumentEnvelope,
|
|
96
156
|
sections: ResolvedDocumentSection[],
|
|
97
157
|
mainSurface: EditorSurfaceSnapshot,
|
|
158
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
98
159
|
): DocumentPageSnapshot[] {
|
|
160
|
+
return buildPageStackWithSplits(document, sections, mainSurface, measurementProvider).pages;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Full page-stack computation that also reports per-block slice metadata
|
|
165
|
+
* produced by intra-paragraph line-box pagination.
|
|
166
|
+
*
|
|
167
|
+
* Consumers that need to project blocks into per-page render targets
|
|
168
|
+
* (see `project-block-fragments.ts`) use the `splits` map to emit one
|
|
169
|
+
* `RuntimeBlockFragment` per slice.
|
|
170
|
+
*/
|
|
171
|
+
export function buildPageStackWithSplits(
|
|
172
|
+
document: CanonicalDocumentEnvelope,
|
|
173
|
+
sections: ResolvedDocumentSection[],
|
|
174
|
+
mainSurface: EditorSurfaceSnapshot,
|
|
175
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
176
|
+
): PageStackResultWithSplits {
|
|
99
177
|
const pages: DocumentPageSnapshot[] = [];
|
|
178
|
+
const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
|
|
100
179
|
let globalPageIndex = 0;
|
|
101
180
|
|
|
102
181
|
for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
|
|
@@ -149,18 +228,21 @@ export function buildPageStack(
|
|
|
149
228
|
}
|
|
150
229
|
}
|
|
151
230
|
|
|
152
|
-
const
|
|
231
|
+
const paginatedResult = paginateSectionBlocksWithSplits(
|
|
153
232
|
section,
|
|
154
233
|
sectionBlocks,
|
|
155
234
|
layout,
|
|
156
235
|
document.subParts?.footnoteCollection,
|
|
236
|
+
measurementProvider,
|
|
157
237
|
);
|
|
238
|
+
const paginated = paginatedResult.pages;
|
|
158
239
|
|
|
159
240
|
// continuous / nextColumn: merge the first page of this section into the
|
|
160
241
|
// previous section's last page (same visual sheet of paper, different
|
|
161
242
|
// semantic section). The merged page keeps the PREVIOUS section's
|
|
162
243
|
// layout because page geometry cannot change mid-page in OOXML.
|
|
163
244
|
let firstPageHandled = false;
|
|
245
|
+
let mergedIntoGlobalPageIndex: number | null = null;
|
|
164
246
|
if (
|
|
165
247
|
isContinuous &&
|
|
166
248
|
pages.length > 0 &&
|
|
@@ -177,17 +259,40 @@ export function buildPageStack(
|
|
|
177
259
|
firstNewPage.endOffset,
|
|
178
260
|
);
|
|
179
261
|
firstPageHandled = true;
|
|
262
|
+
mergedIntoGlobalPageIndex = previousPage.pageIndex;
|
|
180
263
|
}
|
|
181
264
|
|
|
265
|
+
// Map pageInSection → global pageIndex so we can resolve slice metadata.
|
|
266
|
+
const pageInSectionToGlobal = new Map<number, number>();
|
|
182
267
|
for (let i = 0; i < paginated.length; i += 1) {
|
|
183
|
-
if (firstPageHandled && i === 0) continue;
|
|
184
268
|
const page = paginated[i]!;
|
|
269
|
+
if (firstPageHandled && i === 0) {
|
|
270
|
+
if (mergedIntoGlobalPageIndex !== null) {
|
|
271
|
+
pageInSectionToGlobal.set(page.pageInSection, mergedIntoGlobalPageIndex);
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
pageInSectionToGlobal.set(page.pageInSection, globalPageIndex);
|
|
185
276
|
pages.push({
|
|
186
277
|
...page,
|
|
187
278
|
pageIndex: globalPageIndex,
|
|
188
279
|
});
|
|
189
280
|
globalPageIndex += 1;
|
|
190
281
|
}
|
|
282
|
+
|
|
283
|
+
// Resolve per-section splits to global pageIndex and merge into splitsByBlock.
|
|
284
|
+
for (const [blockId, localSlices] of paginatedResult.splits.byBlockId) {
|
|
285
|
+
const existing = splitsByBlock.get(blockId) ?? [];
|
|
286
|
+
for (const localSlice of localSlices) {
|
|
287
|
+
const globalPageIdx = pageInSectionToGlobal.get(localSlice.pageInSection);
|
|
288
|
+
if (globalPageIdx === undefined) continue; // defensive — paginated might include filler
|
|
289
|
+
existing.push({
|
|
290
|
+
pageIndex: globalPageIdx,
|
|
291
|
+
lineRange: localSlice.lineRange,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (existing.length > 0) splitsByBlock.set(blockId, existing);
|
|
295
|
+
}
|
|
191
296
|
}
|
|
192
297
|
|
|
193
298
|
// Guarantee at least one page
|
|
@@ -207,7 +312,11 @@ export function buildPageStack(
|
|
|
207
312
|
}
|
|
208
313
|
|
|
209
314
|
applyWidowControlPass(pages, mainSurface);
|
|
210
|
-
|
|
315
|
+
const tableSplitsByBlock = collectTableRowSlices(mainSurface.blocks, pages);
|
|
316
|
+
return {
|
|
317
|
+
pages,
|
|
318
|
+
splits: { byBlockId: splitsByBlock, tablesByBlockId: tableSplitsByBlock },
|
|
319
|
+
};
|
|
211
320
|
}
|
|
212
321
|
|
|
213
322
|
/**
|
|
@@ -228,18 +337,130 @@ export function buildPageStackFrom(
|
|
|
228
337
|
sections: readonly ResolvedDocumentSection[],
|
|
229
338
|
mainSurface: EditorSurfaceSnapshot,
|
|
230
339
|
resumeAt: { startPageIndex: number; startOffset: number },
|
|
340
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
231
341
|
): DocumentPageSnapshot[] {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
342
|
+
return buildPageStackFromWithSplits(
|
|
343
|
+
document,
|
|
344
|
+
sections,
|
|
345
|
+
mainSurface,
|
|
346
|
+
resumeAt,
|
|
347
|
+
measurementProvider,
|
|
348
|
+
).pages;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resumable variant that also reports per-block slices (filtered to pages at
|
|
353
|
+
* or after `resumeAt.startPageIndex`).
|
|
354
|
+
*/
|
|
355
|
+
export function buildPageStackFromWithSplits(
|
|
356
|
+
document: CanonicalDocumentEnvelope,
|
|
357
|
+
sections: readonly ResolvedDocumentSection[],
|
|
358
|
+
mainSurface: EditorSurfaceSnapshot,
|
|
359
|
+
resumeAt: { startPageIndex: number; startOffset: number },
|
|
360
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
361
|
+
): PageStackResultWithSplits {
|
|
235
362
|
void resumeAt.startOffset;
|
|
236
|
-
const full =
|
|
363
|
+
const full = buildPageStackWithSplits(
|
|
237
364
|
document,
|
|
238
365
|
sections as ResolvedDocumentSection[],
|
|
239
366
|
mainSurface,
|
|
367
|
+
measurementProvider,
|
|
240
368
|
);
|
|
241
369
|
const startIndex = Math.max(0, resumeAt.startPageIndex);
|
|
242
|
-
|
|
370
|
+
const tailPages = full.pages.slice(startIndex);
|
|
371
|
+
const tailSplits = new Map<string, ParagraphLineSlice[]>();
|
|
372
|
+
for (const [blockId, slices] of full.splits.byBlockId) {
|
|
373
|
+
const tail = slices.filter((s) => s.pageIndex >= startIndex);
|
|
374
|
+
if (tail.length > 0) tailSplits.set(blockId, tail);
|
|
375
|
+
}
|
|
376
|
+
const tailTableSplits = new Map<string, TableRowSlice[]>();
|
|
377
|
+
for (const [blockId, slices] of full.splits.tablesByBlockId) {
|
|
378
|
+
const tail = slices.filter((s) => s.pageIndex >= startIndex);
|
|
379
|
+
if (tail.length > 0) tailTableSplits.set(blockId, tail);
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
pages: tailPages,
|
|
383
|
+
splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// R3: table row-slice collection
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Compute `TableRowSlice[]` entries for table blocks that span multiple pages.
|
|
393
|
+
*
|
|
394
|
+
* Tables are currently placed atomically by `paginateSectionBlocks` — the
|
|
395
|
+
* engine never splits a table mid-row. As a consequence the offset range of
|
|
396
|
+
* every table block falls inside exactly one page's `[startOffset, endOffset)`,
|
|
397
|
+
* and this function returns an empty map.
|
|
398
|
+
*
|
|
399
|
+
* The function is wired so that when row-level table pagination lands (the
|
|
400
|
+
* engine emits `pushPage` mid-table, making `pages[]` contain a page whose
|
|
401
|
+
* `startOffset` lands between two rows of a table), the same walk
|
|
402
|
+
* automatically groups rows by page and emits `TableRowSlice` entries
|
|
403
|
+
* without any further schema change.
|
|
404
|
+
*
|
|
405
|
+
* Row offsets are derived from `row.cells[0].content[0].from` — the first
|
|
406
|
+
* cell's first inner block. Rows whose first cell has no paragraph fall
|
|
407
|
+
* back to the table's own `from` so they group with the table's origin page.
|
|
408
|
+
*/
|
|
409
|
+
function collectTableRowSlices(
|
|
410
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
411
|
+
pages: readonly DocumentPageSnapshot[],
|
|
412
|
+
): Map<string, TableRowSlice[]> {
|
|
413
|
+
const result = new Map<string, TableRowSlice[]>();
|
|
414
|
+
if (pages.length === 0) return result;
|
|
415
|
+
|
|
416
|
+
const findPageIndex = (offset: number): number | null => {
|
|
417
|
+
for (const page of pages) {
|
|
418
|
+
if (offset >= page.startOffset && offset < page.endOffset) {
|
|
419
|
+
return page.pageIndex;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const last = pages[pages.length - 1];
|
|
423
|
+
if (last && offset >= last.startOffset && offset <= last.endOffset) {
|
|
424
|
+
return last.pageIndex;
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
for (const block of blocks) {
|
|
430
|
+
if (block.kind !== "table") continue;
|
|
431
|
+
const totalRows = block.rows.length;
|
|
432
|
+
if (totalRows === 0) continue;
|
|
433
|
+
|
|
434
|
+
// Walk rows and assign each to the page containing its start offset.
|
|
435
|
+
const perPageRange = new Map<number, { from: number; to: number }>();
|
|
436
|
+
for (let rowIndex = 0; rowIndex < totalRows; rowIndex += 1) {
|
|
437
|
+
const row = block.rows[rowIndex]!;
|
|
438
|
+
const rowStart = row.cells[0]?.content[0]?.from ?? block.from;
|
|
439
|
+
const pageIndex = findPageIndex(rowStart);
|
|
440
|
+
if (pageIndex === null) continue;
|
|
441
|
+
const existing = perPageRange.get(pageIndex);
|
|
442
|
+
if (existing) {
|
|
443
|
+
existing.to = rowIndex + 1;
|
|
444
|
+
} else {
|
|
445
|
+
perPageRange.set(pageIndex, { from: rowIndex, to: rowIndex + 1 });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Single-page tables produce no slices.
|
|
450
|
+
if (perPageRange.size <= 1) continue;
|
|
451
|
+
|
|
452
|
+
const slices: TableRowSlice[] = [];
|
|
453
|
+
for (const [pageIndex, range] of perPageRange) {
|
|
454
|
+
slices.push({
|
|
455
|
+
pageIndex,
|
|
456
|
+
rowRange: { from: range.from, to: range.to, totalRows },
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
slices.sort((a, b) => a.pageIndex - b.pageIndex);
|
|
460
|
+
result.set(block.blockId, slices);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return result;
|
|
243
464
|
}
|
|
244
465
|
|
|
245
466
|
// ---------------------------------------------------------------------------
|
|
@@ -342,10 +563,15 @@ const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
|
|
|
342
563
|
/**
|
|
343
564
|
* Compute block height using resolved formatting when available.
|
|
344
565
|
* Uses improved table measurement for legal contracts.
|
|
566
|
+
*
|
|
567
|
+
* When `measurementProvider` is supplied, paragraph line counts are produced
|
|
568
|
+
* by `provider.measureLineFragments(...)`; otherwise the inline empirical
|
|
569
|
+
* path runs (which matches the empirical backend numerically).
|
|
345
570
|
*/
|
|
346
571
|
function measureBlockHeight(
|
|
347
572
|
block: SurfaceBlockSnapshot | undefined,
|
|
348
573
|
columnWidth: number,
|
|
574
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
349
575
|
): number {
|
|
350
576
|
if (!block) return 0;
|
|
351
577
|
|
|
@@ -353,17 +579,26 @@ function measureBlockHeight(
|
|
|
353
579
|
case "paragraph": {
|
|
354
580
|
const formatting = resolveBlockFormatting(block);
|
|
355
581
|
if (formatting) {
|
|
356
|
-
const lineCount = measureParagraphLineCount(
|
|
582
|
+
const lineCount = measureParagraphLineCount(
|
|
583
|
+
block,
|
|
584
|
+
formatting,
|
|
585
|
+
columnWidth,
|
|
586
|
+
measurementProvider,
|
|
587
|
+
);
|
|
357
588
|
return calculateParagraphHeight(formatting, lineCount);
|
|
358
589
|
}
|
|
359
590
|
return estimateBlockHeight(block, columnWidth);
|
|
360
591
|
}
|
|
361
592
|
case "table":
|
|
362
|
-
return measureTableHeight(block, columnWidth);
|
|
593
|
+
return measureTableHeight(block, columnWidth, measurementProvider);
|
|
363
594
|
case "sdt_block":
|
|
364
595
|
return Math.max(
|
|
365
596
|
MIN_BLOCK_HEIGHT_TWIPS,
|
|
366
|
-
block.children.reduce(
|
|
597
|
+
block.children.reduce(
|
|
598
|
+
(total, child) =>
|
|
599
|
+
total + measureBlockHeight(child, columnWidth, measurementProvider),
|
|
600
|
+
0,
|
|
601
|
+
),
|
|
367
602
|
);
|
|
368
603
|
case "opaque_block":
|
|
369
604
|
return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
|
|
@@ -372,33 +607,76 @@ function measureBlockHeight(
|
|
|
372
607
|
|
|
373
608
|
/**
|
|
374
609
|
* Improved table height estimation.
|
|
610
|
+
*
|
|
375
611
|
* Uses resolved formatting for cell content paragraphs and respects
|
|
376
612
|
* explicit row heights and height rules.
|
|
613
|
+
*
|
|
614
|
+
* Per-cell width is derived from the table's `gridColumns` and each
|
|
615
|
+
* cell's `colspan` (honoring `gridBefore`/`gridAfter` row padding).
|
|
616
|
+
* This replaces the prior `columnWidth / cellCount` approximation,
|
|
617
|
+
* which was wrong whenever columns carried non-uniform widths or any
|
|
618
|
+
* cell had `colspan > 1`.
|
|
377
619
|
*/
|
|
378
620
|
function measureTableHeight(
|
|
379
621
|
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
|
|
380
622
|
columnWidth: number,
|
|
623
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
381
624
|
): number {
|
|
382
625
|
const TABLE_ROW_PADDING_TWIPS = 120;
|
|
383
626
|
let totalHeight = 0;
|
|
384
627
|
|
|
628
|
+
const gridColumnCount = block.gridColumns.length;
|
|
629
|
+
const totalGridTwips = block.gridColumns.reduce((sum, w) => sum + w, 0);
|
|
630
|
+
// Scale the canonical gridColumns to the available column width so that
|
|
631
|
+
// a table defined in 9000-twip grid on a 12240-twip canvas measures
|
|
632
|
+
// against the actual canvas width, not the OOXML-declared width.
|
|
633
|
+
const gridScale =
|
|
634
|
+
totalGridTwips > 0 && columnWidth > 0 ? columnWidth / totalGridTwips : 1;
|
|
635
|
+
|
|
385
636
|
for (const row of block.rows) {
|
|
386
637
|
const explicitHeight = row.height ?? 0;
|
|
387
638
|
const heightRule = row.heightRule ?? "auto";
|
|
639
|
+
const gridBefore = row.gridBefore ?? 0;
|
|
388
640
|
|
|
389
|
-
// Calculate content-driven height
|
|
641
|
+
// Calculate content-driven height using real per-cell widths.
|
|
390
642
|
let contentHeight = MIN_BLOCK_HEIGHT_TWIPS;
|
|
391
|
-
|
|
392
|
-
const cellWidth = Math.max(720, Math.floor(columnWidth / cellCount));
|
|
643
|
+
let columnCursor = gridBefore;
|
|
393
644
|
|
|
394
645
|
for (const cell of row.cells) {
|
|
646
|
+
const span = Math.max(1, cell.colspan ?? 1);
|
|
647
|
+
const cellWidth = resolveCellWidth(
|
|
648
|
+
block.gridColumns,
|
|
649
|
+
columnCursor,
|
|
650
|
+
span,
|
|
651
|
+
columnWidth,
|
|
652
|
+
gridScale,
|
|
653
|
+
);
|
|
654
|
+
columnCursor += span;
|
|
655
|
+
|
|
656
|
+
if (cell.verticalMerge === "continue") {
|
|
657
|
+
// Continuation cells don't contribute their own content height —
|
|
658
|
+
// the origin cell's height covers the whole span.
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
395
662
|
let cellContentHeight = 0;
|
|
396
663
|
for (const child of cell.content) {
|
|
397
|
-
cellContentHeight += measureBlockHeight(
|
|
664
|
+
cellContentHeight += measureBlockHeight(
|
|
665
|
+
child,
|
|
666
|
+
cellWidth,
|
|
667
|
+
measurementProvider,
|
|
668
|
+
);
|
|
398
669
|
}
|
|
399
670
|
contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
|
|
400
671
|
}
|
|
401
672
|
|
|
673
|
+
// Sanity fallback if the row declared more columns than the grid
|
|
674
|
+
// (malformed input) — clamp the cursor back so subsequent rows
|
|
675
|
+
// continue to measure without throwing.
|
|
676
|
+
if (gridColumnCount > 0 && columnCursor > gridColumnCount) {
|
|
677
|
+
// no-op; kept for documentation — width resolution handles overflow.
|
|
678
|
+
}
|
|
679
|
+
|
|
402
680
|
if (heightRule === "exact" && explicitHeight > 0) {
|
|
403
681
|
totalHeight += explicitHeight;
|
|
404
682
|
} else if (heightRule === "atLeast" && explicitHeight > 0) {
|
|
@@ -413,15 +691,78 @@ function measureTableHeight(
|
|
|
413
691
|
return Math.max(MIN_BLOCK_HEIGHT_TWIPS, totalHeight);
|
|
414
692
|
}
|
|
415
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Sum the widths of `columnSpan` columns starting at `startColumn` from
|
|
696
|
+
* the table's `gridColumns`, scaled to the available column width.
|
|
697
|
+
*
|
|
698
|
+
* Falls back to an even split of `fallbackColumnWidth` when the grid has
|
|
699
|
+
* no entries (no `gridColumns` declared).
|
|
700
|
+
*
|
|
701
|
+
* Exported via `__resolveCellWidth` for unit tests; not part of the
|
|
702
|
+
* stable surface.
|
|
703
|
+
*/
|
|
704
|
+
export function __resolveCellWidth(
|
|
705
|
+
gridColumns: readonly number[],
|
|
706
|
+
startColumn: number,
|
|
707
|
+
columnSpan: number,
|
|
708
|
+
fallbackColumnWidth: number,
|
|
709
|
+
gridScale: number,
|
|
710
|
+
): number {
|
|
711
|
+
return resolveCellWidth(gridColumns, startColumn, columnSpan, fallbackColumnWidth, gridScale);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function resolveCellWidth(
|
|
715
|
+
gridColumns: readonly number[],
|
|
716
|
+
startColumn: number,
|
|
717
|
+
columnSpan: number,
|
|
718
|
+
fallbackColumnWidth: number,
|
|
719
|
+
gridScale: number,
|
|
720
|
+
): number {
|
|
721
|
+
if (gridColumns.length === 0) {
|
|
722
|
+
// No grid declared — best-effort even split of the canvas.
|
|
723
|
+
return Math.max(240, Math.floor(fallbackColumnWidth));
|
|
724
|
+
}
|
|
725
|
+
let gridWidth = 0;
|
|
726
|
+
for (let i = 0; i < columnSpan; i += 1) {
|
|
727
|
+
const column = startColumn + i;
|
|
728
|
+
if (column < 0 || column >= gridColumns.length) continue;
|
|
729
|
+
gridWidth += gridColumns[column] ?? 0;
|
|
730
|
+
}
|
|
731
|
+
const scaled = Math.floor(gridWidth * gridScale);
|
|
732
|
+
return Math.max(240, scaled);
|
|
733
|
+
}
|
|
734
|
+
|
|
416
735
|
/**
|
|
417
736
|
* Count lines in a paragraph using resolved formatting.
|
|
418
737
|
* Accounts for proper indentation, font metrics, and numbering geometry.
|
|
738
|
+
*
|
|
739
|
+
* When a measurement provider is supplied, delegates line counting to the
|
|
740
|
+
* provider's `measureLineFragments`. The provider's empirical backend
|
|
741
|
+
* returns the same numerical result as the inline path, so switching does
|
|
742
|
+
* not change pagination behavior; the canvas backend returns canvas-
|
|
743
|
+
* measured line counts once fonts resolve.
|
|
419
744
|
*/
|
|
420
745
|
function measureParagraphLineCount(
|
|
421
746
|
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
422
747
|
formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
|
|
423
748
|
columnWidth: number,
|
|
749
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
424
750
|
): number {
|
|
751
|
+
if (measurementProvider) {
|
|
752
|
+
const measured = measurementProvider.measureLineFragments({
|
|
753
|
+
block,
|
|
754
|
+
formatting,
|
|
755
|
+
// The paginated pipeline currently resolves formatting at the
|
|
756
|
+
// paragraph level only; per-run formatting is not yet threaded
|
|
757
|
+
// through. Pass an empty map; the provider's empirical backend
|
|
758
|
+
// does not consult per-run metrics and the canvas backend falls
|
|
759
|
+
// back to the paragraph defaults when a run is missing.
|
|
760
|
+
runs: new Map(),
|
|
761
|
+
columnWidth,
|
|
762
|
+
});
|
|
763
|
+
return Math.max(1, measured.lineCount);
|
|
764
|
+
}
|
|
765
|
+
|
|
425
766
|
const firstLineWidth = resolveTextWidth(formatting, columnWidth, true);
|
|
426
767
|
const subsequentLineWidth = resolveTextWidth(formatting, columnWidth, false);
|
|
427
768
|
const firstLineCapacity = resolveCharsPerLine(firstLineWidth, formatting.averageCharWidthTwips);
|
|
@@ -529,25 +870,66 @@ function collectSectionBlocks(
|
|
|
529
870
|
return blocks.filter((block) => block.to > start && block.from < end);
|
|
530
871
|
}
|
|
531
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Per-section slice key: paragraphs carry `pageInSection` scoped to this
|
|
875
|
+
* section's pagination. The outer `buildPageStackWithSplits` resolves these
|
|
876
|
+
* to global page indices.
|
|
877
|
+
*/
|
|
878
|
+
interface SectionLocalSlice {
|
|
879
|
+
pageInSection: number;
|
|
880
|
+
lineRange: { from: number; to: number; totalLines: number };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
interface SectionPaginationResult {
|
|
884
|
+
pages: Omit<DocumentPageSnapshot, "pageIndex">[];
|
|
885
|
+
splits: { byBlockId: Map<string, SectionLocalSlice[]> };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Backwards-compatible wrapper that returns only the pages. Internal to the
|
|
890
|
+
* module; kept to avoid threading the extra map through ancient call sites
|
|
891
|
+
* that don't need it.
|
|
892
|
+
*/
|
|
532
893
|
function paginateSectionBlocks(
|
|
533
894
|
section: ResolvedDocumentSection,
|
|
534
895
|
blocks: readonly SurfaceBlockSnapshot[],
|
|
535
896
|
layout: DocumentPageSnapshot["layout"],
|
|
536
897
|
footnotes: FootnoteCollection | undefined,
|
|
898
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
537
899
|
): Omit<DocumentPageSnapshot, "pageIndex">[] {
|
|
900
|
+
return paginateSectionBlocksWithSplits(
|
|
901
|
+
section,
|
|
902
|
+
blocks,
|
|
903
|
+
layout,
|
|
904
|
+
footnotes,
|
|
905
|
+
measurementProvider,
|
|
906
|
+
).pages;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function paginateSectionBlocksWithSplits(
|
|
910
|
+
section: ResolvedDocumentSection,
|
|
911
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
912
|
+
layout: DocumentPageSnapshot["layout"],
|
|
913
|
+
footnotes: FootnoteCollection | undefined,
|
|
914
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
915
|
+
): SectionPaginationResult {
|
|
538
916
|
if (blocks.length === 0) {
|
|
539
|
-
return
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
917
|
+
return {
|
|
918
|
+
pages: [
|
|
919
|
+
{
|
|
920
|
+
sectionIndex: section.index,
|
|
921
|
+
pageInSection: 0,
|
|
922
|
+
startOffset: section.start,
|
|
923
|
+
endOffset: section.end,
|
|
924
|
+
layout,
|
|
925
|
+
},
|
|
926
|
+
],
|
|
927
|
+
splits: { byBlockId: new Map() }, // section-local; global map includes tablesByBlockId via collectTableRowSlices
|
|
928
|
+
};
|
|
548
929
|
}
|
|
549
930
|
|
|
550
931
|
const pages: Omit<DocumentPageSnapshot, "pageIndex">[] = [];
|
|
932
|
+
const splitsByBlock = new Map<string, SectionLocalSlice[]>();
|
|
551
933
|
const usableHeight = getUsablePageHeight(layout);
|
|
552
934
|
const columnMetrics = getUsableColumnMetrics(layout);
|
|
553
935
|
const maxColumns = Math.max(1, columnMetrics.length);
|
|
@@ -585,12 +967,13 @@ function paginateSectionBlocks(
|
|
|
585
967
|
const columnWidth =
|
|
586
968
|
columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
|
|
587
969
|
getUsableColumnWidth(layout);
|
|
588
|
-
const baseHeight = measureBlockHeight(block, columnWidth);
|
|
970
|
+
const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider);
|
|
589
971
|
|
|
590
972
|
// keepNext: this paragraph must stay with the next one on the same page
|
|
591
973
|
const keepWithNextHeight =
|
|
592
974
|
block.kind === "paragraph" && block.keepNext
|
|
593
|
-
? baseHeight +
|
|
975
|
+
? baseHeight +
|
|
976
|
+
measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider)
|
|
594
977
|
: baseHeight;
|
|
595
978
|
|
|
596
979
|
// keepLines: the entire paragraph must fit on one page.
|
|
@@ -616,6 +999,66 @@ function paginateSectionBlocks(
|
|
|
616
999
|
reservedNotes.clear();
|
|
617
1000
|
continue;
|
|
618
1001
|
}
|
|
1002
|
+
|
|
1003
|
+
// Intra-paragraph line-box split attempt. When the overflowing block
|
|
1004
|
+
// is a paragraph with enough lines to split, we record a split
|
|
1005
|
+
// metadata entry so renderers can bleed the first `linesOnCurrent`
|
|
1006
|
+
// lines up onto the prior page's body. Offset-wise the paragraph
|
|
1007
|
+
// still lives entirely on the new page — the splits map is the
|
|
1008
|
+
// contract that tells fragment projection to emit a bleed-up slice.
|
|
1009
|
+
//
|
|
1010
|
+
// This keeps page offset ranges contiguous and non-overlapping
|
|
1011
|
+
// (preserving `findPageNodeForOffset` invariants), while still
|
|
1012
|
+
// letting the UI render the paragraph across the boundary.
|
|
1013
|
+
if (
|
|
1014
|
+
block.kind === "paragraph" &&
|
|
1015
|
+
formatting &&
|
|
1016
|
+
!keepLinesActive &&
|
|
1017
|
+
!block.keepNext
|
|
1018
|
+
) {
|
|
1019
|
+
const availableHeight = usableHeight - columnHeight - reservedNoteHeight;
|
|
1020
|
+
const totalLines = measureParagraphLineCount(
|
|
1021
|
+
block,
|
|
1022
|
+
formatting,
|
|
1023
|
+
columnWidth,
|
|
1024
|
+
measurementProvider,
|
|
1025
|
+
);
|
|
1026
|
+
const availableLines =
|
|
1027
|
+
formatting.lineHeight > 0
|
|
1028
|
+
? Math.max(0, Math.floor(availableHeight / formatting.lineHeight))
|
|
1029
|
+
: 0;
|
|
1030
|
+
const splitRule = paginateParagraphLines({
|
|
1031
|
+
totalLines,
|
|
1032
|
+
availableLines,
|
|
1033
|
+
keepLines: keepLinesActive,
|
|
1034
|
+
widowControl: formatting.widowControl,
|
|
1035
|
+
keepNext: Boolean(block.keepNext),
|
|
1036
|
+
isLastBlockOnPage: index === blocks.length - 1,
|
|
1037
|
+
});
|
|
1038
|
+
if (splitRule) {
|
|
1039
|
+
const bleedUpPageInSection = pageInSection;
|
|
1040
|
+
const anchorPageInSection = pageInSection + 1;
|
|
1041
|
+
splitsByBlock.set(block.blockId, [
|
|
1042
|
+
{
|
|
1043
|
+
pageInSection: bleedUpPageInSection,
|
|
1044
|
+
lineRange: {
|
|
1045
|
+
from: 0,
|
|
1046
|
+
to: splitRule.linesOnCurrent,
|
|
1047
|
+
totalLines,
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
pageInSection: anchorPageInSection,
|
|
1052
|
+
lineRange: {
|
|
1053
|
+
from: splitRule.linesOnCurrent,
|
|
1054
|
+
to: totalLines,
|
|
1055
|
+
totalLines,
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
]);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
619
1062
|
pushPage(block.from);
|
|
620
1063
|
continue;
|
|
621
1064
|
}
|
|
@@ -664,17 +1107,21 @@ function paginateSectionBlocks(
|
|
|
664
1107
|
}
|
|
665
1108
|
}
|
|
666
1109
|
|
|
667
|
-
return
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1110
|
+
return {
|
|
1111
|
+
pages:
|
|
1112
|
+
pages.length > 0
|
|
1113
|
+
? pages
|
|
1114
|
+
: [
|
|
1115
|
+
{
|
|
1116
|
+
sectionIndex: section.index,
|
|
1117
|
+
pageInSection: 0,
|
|
1118
|
+
startOffset: section.start,
|
|
1119
|
+
endOffset: section.end,
|
|
1120
|
+
layout,
|
|
1121
|
+
},
|
|
1122
|
+
],
|
|
1123
|
+
splits: { byBlockId: splitsByBlock },
|
|
1124
|
+
};
|
|
678
1125
|
}
|
|
679
1126
|
|
|
680
1127
|
function estimateFootnoteReservation(
|