@beyondwork/docx-react-component 1.0.37 → 1.0.38
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 +1 -1
- package/src/api/public-types.ts +319 -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 +815 -55
- package/src/io/export/serialize-main-document.ts +2 -11
- package/src/io/export/serialize-numbering.ts +1 -2
- 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-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-tables.ts +249 -0
- package/src/model/canonical-document.ts +34 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +1 -1
- package/src/runtime/document-runtime.ts +114 -0
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/index.ts +45 -0
- package/src/runtime/layout/inert-layout-facet.ts +14 -0
- package/src/runtime/layout/layout-engine-instance.ts +33 -23
- 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 +19 -0
- package/src/runtime/layout/paginated-layout-engine.ts +142 -9
- package/src/runtime/layout/project-block-fragments.ts +91 -0
- package/src/runtime/layout/public-facet.ts +709 -16
- package/src/runtime/layout/table-render-plan.ts +229 -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 +755 -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 +84 -15
- package/src/ui/editor-shell-view.tsx +6 -0
- package/src/ui/headless/chrome-registry.ts +280 -14
- package/src/ui/headless/scoped-chrome-policy.ts +20 -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/role-action-sets.ts +74 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -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/perf-probe.ts +7 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +12 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +3 -0
- package/src/ui-tailwind/index.ts +33 -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 +559 -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 +69 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +136 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-format catalog — named page sizes that back the section page-size
|
|
3
|
+
* picker and the real-dimension page frame.
|
|
4
|
+
*
|
|
5
|
+
* The canonical storage of page geometry remains
|
|
6
|
+
* `SectionProperties.pageSize` in twips. This catalog adds a layer of
|
|
7
|
+
* semantic naming on top so the UI can render "A4" or "US Letter" and
|
|
8
|
+
* the render kernel can pick a sensible default per locale.
|
|
9
|
+
*
|
|
10
|
+
* Unit reference (OOXML):
|
|
11
|
+
* - 1 inch = 1440 twips
|
|
12
|
+
* - 1 mm = 56.6929 twips
|
|
13
|
+
*
|
|
14
|
+
* The catalog deliberately ships a single default match tolerance (1 twip)
|
|
15
|
+
* so legacy documents whose page sizes were round-tripped through third-
|
|
16
|
+
* party tools still identify as the named format they were authored at.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Public types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export type PageFormatId =
|
|
24
|
+
| "letter"
|
|
25
|
+
| "legal"
|
|
26
|
+
| "tabloid"
|
|
27
|
+
| "executive"
|
|
28
|
+
| "a3"
|
|
29
|
+
| "a4"
|
|
30
|
+
| "a5"
|
|
31
|
+
| "b4-iso"
|
|
32
|
+
| "b5-iso"
|
|
33
|
+
| "custom";
|
|
34
|
+
|
|
35
|
+
export type PageFormatRegion = "us" | "iso" | "jp" | "custom";
|
|
36
|
+
|
|
37
|
+
export type PageFormatLocaleDefault = "en-us" | "en-gb" | "eu" | "jp";
|
|
38
|
+
|
|
39
|
+
export interface PageFormatDisplay {
|
|
40
|
+
inches?: string;
|
|
41
|
+
millimeters?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PageFormatDefinition {
|
|
45
|
+
/** Stable identifier. */
|
|
46
|
+
id: PageFormatId;
|
|
47
|
+
/** Human-facing label used in pickers. */
|
|
48
|
+
label: string;
|
|
49
|
+
/** Locale or region hint. */
|
|
50
|
+
region: PageFormatRegion;
|
|
51
|
+
/** Width in twips at portrait orientation. */
|
|
52
|
+
portraitWidthTwips: number;
|
|
53
|
+
/** Height in twips at portrait orientation. */
|
|
54
|
+
portraitHeightTwips: number;
|
|
55
|
+
/** Default-for-locale hint so consumers can pick a sensible default. */
|
|
56
|
+
localeDefault?: PageFormatLocaleDefault;
|
|
57
|
+
/** Pre-computed inches/millimeters strings for the picker. */
|
|
58
|
+
display: PageFormatDisplay;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ActivePageFormat {
|
|
62
|
+
sectionIndex: number;
|
|
63
|
+
format: PageFormatDefinition;
|
|
64
|
+
orientation: "portrait" | "landscape";
|
|
65
|
+
/** True when the section matches a catalog format within the match tolerance. */
|
|
66
|
+
matchesCatalog: boolean;
|
|
67
|
+
/** When matchesCatalog is false, the raw twips (custom format). */
|
|
68
|
+
customTwips?: { width: number; height: number };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Catalog
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pre-defined named page formats. Ordering intentionally puts the most
|
|
77
|
+
* common sizes (Letter, A4) first because UI dropdowns render in list order.
|
|
78
|
+
*/
|
|
79
|
+
export const PAGE_FORMAT_CATALOG: readonly PageFormatDefinition[] = Object.freeze([
|
|
80
|
+
{
|
|
81
|
+
id: "letter",
|
|
82
|
+
label: "US Letter",
|
|
83
|
+
region: "us",
|
|
84
|
+
portraitWidthTwips: 12240,
|
|
85
|
+
portraitHeightTwips: 15840,
|
|
86
|
+
localeDefault: "en-us",
|
|
87
|
+
display: { inches: "8.5 × 11 in", millimeters: "215.9 × 279.4 mm" },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "a4",
|
|
91
|
+
label: "A4",
|
|
92
|
+
region: "iso",
|
|
93
|
+
portraitWidthTwips: 11906,
|
|
94
|
+
portraitHeightTwips: 16838,
|
|
95
|
+
localeDefault: "eu",
|
|
96
|
+
display: { inches: "8.27 × 11.69 in", millimeters: "210 × 297 mm" },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "legal",
|
|
100
|
+
label: "US Legal",
|
|
101
|
+
region: "us",
|
|
102
|
+
portraitWidthTwips: 12240,
|
|
103
|
+
portraitHeightTwips: 20160,
|
|
104
|
+
display: { inches: "8.5 × 14 in", millimeters: "215.9 × 355.6 mm" },
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "tabloid",
|
|
108
|
+
label: "Tabloid / Ledger",
|
|
109
|
+
region: "us",
|
|
110
|
+
portraitWidthTwips: 15840,
|
|
111
|
+
portraitHeightTwips: 24480,
|
|
112
|
+
display: { inches: "11 × 17 in", millimeters: "279.4 × 431.8 mm" },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "executive",
|
|
116
|
+
label: "Executive",
|
|
117
|
+
region: "us",
|
|
118
|
+
portraitWidthTwips: 10440,
|
|
119
|
+
portraitHeightTwips: 15120,
|
|
120
|
+
display: { inches: "7.25 × 10.5 in", millimeters: "184.1 × 266.7 mm" },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "a3",
|
|
124
|
+
label: "A3",
|
|
125
|
+
region: "iso",
|
|
126
|
+
portraitWidthTwips: 16838,
|
|
127
|
+
portraitHeightTwips: 23811,
|
|
128
|
+
display: { inches: "11.69 × 16.54 in", millimeters: "297 × 420 mm" },
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "a5",
|
|
132
|
+
label: "A5",
|
|
133
|
+
region: "iso",
|
|
134
|
+
portraitWidthTwips: 8391,
|
|
135
|
+
portraitHeightTwips: 11906,
|
|
136
|
+
display: { inches: "5.83 × 8.27 in", millimeters: "148 × 210 mm" },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "b4-iso",
|
|
140
|
+
label: "B4 (ISO)",
|
|
141
|
+
region: "iso",
|
|
142
|
+
portraitWidthTwips: 14173,
|
|
143
|
+
portraitHeightTwips: 20016,
|
|
144
|
+
display: { inches: "9.84 × 13.90 in", millimeters: "250 × 353 mm" },
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "b5-iso",
|
|
148
|
+
label: "B5 (ISO)",
|
|
149
|
+
region: "iso",
|
|
150
|
+
portraitWidthTwips: 9977,
|
|
151
|
+
portraitHeightTwips: 14173,
|
|
152
|
+
display: { inches: "6.93 × 9.84 in", millimeters: "176 × 250 mm" },
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "custom",
|
|
156
|
+
label: "Custom",
|
|
157
|
+
region: "custom",
|
|
158
|
+
portraitWidthTwips: 0,
|
|
159
|
+
portraitHeightTwips: 0,
|
|
160
|
+
display: {},
|
|
161
|
+
},
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Match tolerance in twips. Historic round-trips through third-party tools
|
|
166
|
+
* sometimes drift by sub-twip amounts; a 1-twip window catches those while
|
|
167
|
+
* keeping intentionally custom sizes genuinely custom.
|
|
168
|
+
*/
|
|
169
|
+
const MATCH_TOLERANCE_TWIPS = 1;
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Matching
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export interface MatchPageFormatInput {
|
|
176
|
+
sectionIndex: number;
|
|
177
|
+
widthTwips: number;
|
|
178
|
+
heightTwips: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Match a section's page dimensions against the catalog.
|
|
183
|
+
*
|
|
184
|
+
* The matcher tolerates either orientation — if the supplied (width, height)
|
|
185
|
+
* match a catalog entry rotated by 90°, the active format is reported as
|
|
186
|
+
* `landscape`. Custom sizes fall through to the `custom` entry.
|
|
187
|
+
*/
|
|
188
|
+
export function matchPageFormat(input: MatchPageFormatInput): ActivePageFormat {
|
|
189
|
+
const { sectionIndex, widthTwips, heightTwips } = input;
|
|
190
|
+
|
|
191
|
+
for (const format of PAGE_FORMAT_CATALOG) {
|
|
192
|
+
if (format.id === "custom") continue;
|
|
193
|
+
|
|
194
|
+
const portraitMatch =
|
|
195
|
+
near(widthTwips, format.portraitWidthTwips) &&
|
|
196
|
+
near(heightTwips, format.portraitHeightTwips);
|
|
197
|
+
const landscapeMatch =
|
|
198
|
+
near(widthTwips, format.portraitHeightTwips) &&
|
|
199
|
+
near(heightTwips, format.portraitWidthTwips);
|
|
200
|
+
|
|
201
|
+
if (portraitMatch || landscapeMatch) {
|
|
202
|
+
return {
|
|
203
|
+
sectionIndex,
|
|
204
|
+
format,
|
|
205
|
+
orientation: portraitMatch ? "portrait" : "landscape",
|
|
206
|
+
matchesCatalog: true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const custom = PAGE_FORMAT_CATALOG.find((f) => f.id === "custom")!;
|
|
212
|
+
return {
|
|
213
|
+
sectionIndex,
|
|
214
|
+
format: custom,
|
|
215
|
+
orientation: widthTwips > heightTwips ? "landscape" : "portrait",
|
|
216
|
+
matchesCatalog: false,
|
|
217
|
+
customTwips: { width: widthTwips, height: heightTwips },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function near(a: number, b: number): boolean {
|
|
222
|
+
return Math.abs(a - b) <= MATCH_TOLERANCE_TWIPS;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Look up a format by id. Returns the `custom` entry for unknown ids so
|
|
227
|
+
* callers can chain `.format` reads without null-checking.
|
|
228
|
+
*/
|
|
229
|
+
export function getPageFormatById(id: string): PageFormatDefinition {
|
|
230
|
+
const found = PAGE_FORMAT_CATALOG.find((f) => f.id === id);
|
|
231
|
+
if (found) return found;
|
|
232
|
+
return PAGE_FORMAT_CATALOG.find((f) => f.id === "custom")!;
|
|
233
|
+
}
|
|
@@ -148,6 +148,16 @@ export interface BuildPageGraphInput {
|
|
|
148
148
|
/** Optional block fragments pre-computed by pagination; when omitted the
|
|
149
149
|
* graph produces one fragment per page spanning its entire offset range. */
|
|
150
150
|
fragments?: readonly RuntimeBlockFragment[];
|
|
151
|
+
/**
|
|
152
|
+
* Optional block fragments keyed by pageIndex with the `pageId` omitted.
|
|
153
|
+
* `buildPageGraph` fills in the pageId using the graph's fresh revision
|
|
154
|
+
* stamp. Use this when the caller wants to emit per-block fragments but
|
|
155
|
+
* cannot know the pageId in advance.
|
|
156
|
+
*/
|
|
157
|
+
fragmentsByPageIndex?: ReadonlyMap<
|
|
158
|
+
number,
|
|
159
|
+
ReadonlyArray<Omit<RuntimeBlockFragment, "pageId">>
|
|
160
|
+
>;
|
|
151
161
|
/** Optional per-page line boxes. */
|
|
152
162
|
lineBoxes?: ReadonlyMap<string, RuntimeLineBox[]>;
|
|
153
163
|
/** Optional per-page note allocations. */
|
|
@@ -177,6 +187,15 @@ export function buildPageGraph(
|
|
|
177
187
|
|
|
178
188
|
const pages: RuntimePageNode[] = [];
|
|
179
189
|
const aggregatedFragments: RuntimeBlockFragment[] = [...(input.fragments ?? [])];
|
|
190
|
+
// Rehydrate fragmentsByPageIndex with the fresh graphRevision's pageIds.
|
|
191
|
+
if (input.fragmentsByPageIndex) {
|
|
192
|
+
for (const [pageIndex, fragments] of input.fragmentsByPageIndex) {
|
|
193
|
+
const pageId = `page-${graphRevision}-${pageIndex}`;
|
|
194
|
+
for (const fragment of fragments) {
|
|
195
|
+
aggregatedFragments.push({ ...fragment, pageId });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
180
199
|
const anchors: RuntimePageAnchor[] = [];
|
|
181
200
|
|
|
182
201
|
for (let index = 0; index < input.pages.length; index += 1) {
|
|
@@ -61,6 +61,7 @@ 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";
|
|
64
65
|
|
|
65
66
|
// ---------------------------------------------------------------------------
|
|
66
67
|
// Types
|
|
@@ -90,11 +91,18 @@ export interface PageStackResult {
|
|
|
90
91
|
* This is the single entry point for page composition. All consumers
|
|
91
92
|
* (document-navigation, view-state, page mode) should call this instead
|
|
92
93
|
* of directly using the estimation helpers.
|
|
94
|
+
*
|
|
95
|
+
* `measurementProvider` is optional. When supplied, paragraph line counts
|
|
96
|
+
* and block heights consult the provider's `measureLineFragments` so the
|
|
97
|
+
* canvas backend can participate. When omitted, measurement falls through
|
|
98
|
+
* to the empirical path baked into this module — which matches the
|
|
99
|
+
* provider's empirical backend numerically, keeping pagination stable.
|
|
93
100
|
*/
|
|
94
101
|
export function buildPageStack(
|
|
95
102
|
document: CanonicalDocumentEnvelope,
|
|
96
103
|
sections: ResolvedDocumentSection[],
|
|
97
104
|
mainSurface: EditorSurfaceSnapshot,
|
|
105
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
98
106
|
): DocumentPageSnapshot[] {
|
|
99
107
|
const pages: DocumentPageSnapshot[] = [];
|
|
100
108
|
let globalPageIndex = 0;
|
|
@@ -154,6 +162,7 @@ export function buildPageStack(
|
|
|
154
162
|
sectionBlocks,
|
|
155
163
|
layout,
|
|
156
164
|
document.subParts?.footnoteCollection,
|
|
165
|
+
measurementProvider,
|
|
157
166
|
);
|
|
158
167
|
|
|
159
168
|
// continuous / nextColumn: merge the first page of this section into the
|
|
@@ -228,6 +237,7 @@ export function buildPageStackFrom(
|
|
|
228
237
|
sections: readonly ResolvedDocumentSection[],
|
|
229
238
|
mainSurface: EditorSurfaceSnapshot,
|
|
230
239
|
resumeAt: { startPageIndex: number; startOffset: number },
|
|
240
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
231
241
|
): DocumentPageSnapshot[] {
|
|
232
242
|
// Correctness-first: run the full pipeline and return pages from the
|
|
233
243
|
// requested start. `startOffset` is accepted for forward compatibility
|
|
@@ -237,6 +247,7 @@ export function buildPageStackFrom(
|
|
|
237
247
|
document,
|
|
238
248
|
sections as ResolvedDocumentSection[],
|
|
239
249
|
mainSurface,
|
|
250
|
+
measurementProvider,
|
|
240
251
|
);
|
|
241
252
|
const startIndex = Math.max(0, resumeAt.startPageIndex);
|
|
242
253
|
return full.slice(startIndex);
|
|
@@ -342,10 +353,15 @@ const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
|
|
|
342
353
|
/**
|
|
343
354
|
* Compute block height using resolved formatting when available.
|
|
344
355
|
* Uses improved table measurement for legal contracts.
|
|
356
|
+
*
|
|
357
|
+
* When `measurementProvider` is supplied, paragraph line counts are produced
|
|
358
|
+
* by `provider.measureLineFragments(...)`; otherwise the inline empirical
|
|
359
|
+
* path runs (which matches the empirical backend numerically).
|
|
345
360
|
*/
|
|
346
361
|
function measureBlockHeight(
|
|
347
362
|
block: SurfaceBlockSnapshot | undefined,
|
|
348
363
|
columnWidth: number,
|
|
364
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
349
365
|
): number {
|
|
350
366
|
if (!block) return 0;
|
|
351
367
|
|
|
@@ -353,17 +369,26 @@ function measureBlockHeight(
|
|
|
353
369
|
case "paragraph": {
|
|
354
370
|
const formatting = resolveBlockFormatting(block);
|
|
355
371
|
if (formatting) {
|
|
356
|
-
const lineCount = measureParagraphLineCount(
|
|
372
|
+
const lineCount = measureParagraphLineCount(
|
|
373
|
+
block,
|
|
374
|
+
formatting,
|
|
375
|
+
columnWidth,
|
|
376
|
+
measurementProvider,
|
|
377
|
+
);
|
|
357
378
|
return calculateParagraphHeight(formatting, lineCount);
|
|
358
379
|
}
|
|
359
380
|
return estimateBlockHeight(block, columnWidth);
|
|
360
381
|
}
|
|
361
382
|
case "table":
|
|
362
|
-
return measureTableHeight(block, columnWidth);
|
|
383
|
+
return measureTableHeight(block, columnWidth, measurementProvider);
|
|
363
384
|
case "sdt_block":
|
|
364
385
|
return Math.max(
|
|
365
386
|
MIN_BLOCK_HEIGHT_TWIPS,
|
|
366
|
-
block.children.reduce(
|
|
387
|
+
block.children.reduce(
|
|
388
|
+
(total, child) =>
|
|
389
|
+
total + measureBlockHeight(child, columnWidth, measurementProvider),
|
|
390
|
+
0,
|
|
391
|
+
),
|
|
367
392
|
);
|
|
368
393
|
case "opaque_block":
|
|
369
394
|
return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
|
|
@@ -372,33 +397,76 @@ function measureBlockHeight(
|
|
|
372
397
|
|
|
373
398
|
/**
|
|
374
399
|
* Improved table height estimation.
|
|
400
|
+
*
|
|
375
401
|
* Uses resolved formatting for cell content paragraphs and respects
|
|
376
402
|
* explicit row heights and height rules.
|
|
403
|
+
*
|
|
404
|
+
* Per-cell width is derived from the table's `gridColumns` and each
|
|
405
|
+
* cell's `colspan` (honoring `gridBefore`/`gridAfter` row padding).
|
|
406
|
+
* This replaces the prior `columnWidth / cellCount` approximation,
|
|
407
|
+
* which was wrong whenever columns carried non-uniform widths or any
|
|
408
|
+
* cell had `colspan > 1`.
|
|
377
409
|
*/
|
|
378
410
|
function measureTableHeight(
|
|
379
411
|
block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
|
|
380
412
|
columnWidth: number,
|
|
413
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
381
414
|
): number {
|
|
382
415
|
const TABLE_ROW_PADDING_TWIPS = 120;
|
|
383
416
|
let totalHeight = 0;
|
|
384
417
|
|
|
418
|
+
const gridColumnCount = block.gridColumns.length;
|
|
419
|
+
const totalGridTwips = block.gridColumns.reduce((sum, w) => sum + w, 0);
|
|
420
|
+
// Scale the canonical gridColumns to the available column width so that
|
|
421
|
+
// a table defined in 9000-twip grid on a 12240-twip canvas measures
|
|
422
|
+
// against the actual canvas width, not the OOXML-declared width.
|
|
423
|
+
const gridScale =
|
|
424
|
+
totalGridTwips > 0 && columnWidth > 0 ? columnWidth / totalGridTwips : 1;
|
|
425
|
+
|
|
385
426
|
for (const row of block.rows) {
|
|
386
427
|
const explicitHeight = row.height ?? 0;
|
|
387
428
|
const heightRule = row.heightRule ?? "auto";
|
|
429
|
+
const gridBefore = row.gridBefore ?? 0;
|
|
388
430
|
|
|
389
|
-
// Calculate content-driven height
|
|
431
|
+
// Calculate content-driven height using real per-cell widths.
|
|
390
432
|
let contentHeight = MIN_BLOCK_HEIGHT_TWIPS;
|
|
391
|
-
|
|
392
|
-
const cellWidth = Math.max(720, Math.floor(columnWidth / cellCount));
|
|
433
|
+
let columnCursor = gridBefore;
|
|
393
434
|
|
|
394
435
|
for (const cell of row.cells) {
|
|
436
|
+
const span = Math.max(1, cell.colspan ?? 1);
|
|
437
|
+
const cellWidth = resolveCellWidth(
|
|
438
|
+
block.gridColumns,
|
|
439
|
+
columnCursor,
|
|
440
|
+
span,
|
|
441
|
+
columnWidth,
|
|
442
|
+
gridScale,
|
|
443
|
+
);
|
|
444
|
+
columnCursor += span;
|
|
445
|
+
|
|
446
|
+
if (cell.verticalMerge === "continue") {
|
|
447
|
+
// Continuation cells don't contribute their own content height —
|
|
448
|
+
// the origin cell's height covers the whole span.
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
395
452
|
let cellContentHeight = 0;
|
|
396
453
|
for (const child of cell.content) {
|
|
397
|
-
cellContentHeight += measureBlockHeight(
|
|
454
|
+
cellContentHeight += measureBlockHeight(
|
|
455
|
+
child,
|
|
456
|
+
cellWidth,
|
|
457
|
+
measurementProvider,
|
|
458
|
+
);
|
|
398
459
|
}
|
|
399
460
|
contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
|
|
400
461
|
}
|
|
401
462
|
|
|
463
|
+
// Sanity fallback if the row declared more columns than the grid
|
|
464
|
+
// (malformed input) — clamp the cursor back so subsequent rows
|
|
465
|
+
// continue to measure without throwing.
|
|
466
|
+
if (gridColumnCount > 0 && columnCursor > gridColumnCount) {
|
|
467
|
+
// no-op; kept for documentation — width resolution handles overflow.
|
|
468
|
+
}
|
|
469
|
+
|
|
402
470
|
if (heightRule === "exact" && explicitHeight > 0) {
|
|
403
471
|
totalHeight += explicitHeight;
|
|
404
472
|
} else if (heightRule === "atLeast" && explicitHeight > 0) {
|
|
@@ -413,15 +481,78 @@ function measureTableHeight(
|
|
|
413
481
|
return Math.max(MIN_BLOCK_HEIGHT_TWIPS, totalHeight);
|
|
414
482
|
}
|
|
415
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Sum the widths of `columnSpan` columns starting at `startColumn` from
|
|
486
|
+
* the table's `gridColumns`, scaled to the available column width.
|
|
487
|
+
*
|
|
488
|
+
* Falls back to an even split of `fallbackColumnWidth` when the grid has
|
|
489
|
+
* no entries (no `gridColumns` declared).
|
|
490
|
+
*
|
|
491
|
+
* Exported via `__resolveCellWidth` for unit tests; not part of the
|
|
492
|
+
* stable surface.
|
|
493
|
+
*/
|
|
494
|
+
export function __resolveCellWidth(
|
|
495
|
+
gridColumns: readonly number[],
|
|
496
|
+
startColumn: number,
|
|
497
|
+
columnSpan: number,
|
|
498
|
+
fallbackColumnWidth: number,
|
|
499
|
+
gridScale: number,
|
|
500
|
+
): number {
|
|
501
|
+
return resolveCellWidth(gridColumns, startColumn, columnSpan, fallbackColumnWidth, gridScale);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function resolveCellWidth(
|
|
505
|
+
gridColumns: readonly number[],
|
|
506
|
+
startColumn: number,
|
|
507
|
+
columnSpan: number,
|
|
508
|
+
fallbackColumnWidth: number,
|
|
509
|
+
gridScale: number,
|
|
510
|
+
): number {
|
|
511
|
+
if (gridColumns.length === 0) {
|
|
512
|
+
// No grid declared — best-effort even split of the canvas.
|
|
513
|
+
return Math.max(240, Math.floor(fallbackColumnWidth));
|
|
514
|
+
}
|
|
515
|
+
let gridWidth = 0;
|
|
516
|
+
for (let i = 0; i < columnSpan; i += 1) {
|
|
517
|
+
const column = startColumn + i;
|
|
518
|
+
if (column < 0 || column >= gridColumns.length) continue;
|
|
519
|
+
gridWidth += gridColumns[column] ?? 0;
|
|
520
|
+
}
|
|
521
|
+
const scaled = Math.floor(gridWidth * gridScale);
|
|
522
|
+
return Math.max(240, scaled);
|
|
523
|
+
}
|
|
524
|
+
|
|
416
525
|
/**
|
|
417
526
|
* Count lines in a paragraph using resolved formatting.
|
|
418
527
|
* Accounts for proper indentation, font metrics, and numbering geometry.
|
|
528
|
+
*
|
|
529
|
+
* When a measurement provider is supplied, delegates line counting to the
|
|
530
|
+
* provider's `measureLineFragments`. The provider's empirical backend
|
|
531
|
+
* returns the same numerical result as the inline path, so switching does
|
|
532
|
+
* not change pagination behavior; the canvas backend returns canvas-
|
|
533
|
+
* measured line counts once fonts resolve.
|
|
419
534
|
*/
|
|
420
535
|
function measureParagraphLineCount(
|
|
421
536
|
block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
|
|
422
537
|
formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
|
|
423
538
|
columnWidth: number,
|
|
539
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
424
540
|
): number {
|
|
541
|
+
if (measurementProvider) {
|
|
542
|
+
const measured = measurementProvider.measureLineFragments({
|
|
543
|
+
block,
|
|
544
|
+
formatting,
|
|
545
|
+
// The paginated pipeline currently resolves formatting at the
|
|
546
|
+
// paragraph level only; per-run formatting is not yet threaded
|
|
547
|
+
// through. Pass an empty map; the provider's empirical backend
|
|
548
|
+
// does not consult per-run metrics and the canvas backend falls
|
|
549
|
+
// back to the paragraph defaults when a run is missing.
|
|
550
|
+
runs: new Map(),
|
|
551
|
+
columnWidth,
|
|
552
|
+
});
|
|
553
|
+
return Math.max(1, measured.lineCount);
|
|
554
|
+
}
|
|
555
|
+
|
|
425
556
|
const firstLineWidth = resolveTextWidth(formatting, columnWidth, true);
|
|
426
557
|
const subsequentLineWidth = resolveTextWidth(formatting, columnWidth, false);
|
|
427
558
|
const firstLineCapacity = resolveCharsPerLine(firstLineWidth, formatting.averageCharWidthTwips);
|
|
@@ -534,6 +665,7 @@ function paginateSectionBlocks(
|
|
|
534
665
|
blocks: readonly SurfaceBlockSnapshot[],
|
|
535
666
|
layout: DocumentPageSnapshot["layout"],
|
|
536
667
|
footnotes: FootnoteCollection | undefined,
|
|
668
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
537
669
|
): Omit<DocumentPageSnapshot, "pageIndex">[] {
|
|
538
670
|
if (blocks.length === 0) {
|
|
539
671
|
return [
|
|
@@ -585,12 +717,13 @@ function paginateSectionBlocks(
|
|
|
585
717
|
const columnWidth =
|
|
586
718
|
columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
|
|
587
719
|
getUsableColumnWidth(layout);
|
|
588
|
-
const baseHeight = measureBlockHeight(block, columnWidth);
|
|
720
|
+
const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider);
|
|
589
721
|
|
|
590
722
|
// keepNext: this paragraph must stay with the next one on the same page
|
|
591
723
|
const keepWithNextHeight =
|
|
592
724
|
block.kind === "paragraph" && block.keepNext
|
|
593
|
-
? baseHeight +
|
|
725
|
+
? baseHeight +
|
|
726
|
+
measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider)
|
|
594
727
|
: baseHeight;
|
|
595
728
|
|
|
596
729
|
// keepLines: the entire paragraph must fit on one page.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project surface blocks → per-page RuntimeBlockFragments (P4).
|
|
3
|
+
*
|
|
4
|
+
* The pagination engine produces `DocumentPageSnapshot[]` keyed by offset
|
|
5
|
+
* ranges, but for render-kernel consumers to get per-block geometry (and
|
|
6
|
+
* for the chrome to anchor tables, images, etc.) we need one block
|
|
7
|
+
* fragment per top-level surface block, assigned to the page that contains
|
|
8
|
+
* its offset range.
|
|
9
|
+
*
|
|
10
|
+
* Today the engine does not split tables across pages, so this emission
|
|
11
|
+
* pass treats each top-level SurfaceBlockSnapshot as an atomic fragment
|
|
12
|
+
* with `pageIndex` resolved from `pages[i].startOffset..endOffset`. Row-
|
|
13
|
+
* level splitting (for header-row repeat + per-row cantSplit) will refine
|
|
14
|
+
* this to multiple fragments per block.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
DocumentPageSnapshot,
|
|
19
|
+
EditorSurfaceSnapshot,
|
|
20
|
+
SurfaceBlockSnapshot,
|
|
21
|
+
} from "../../api/public-types";
|
|
22
|
+
import type { RuntimeBlockFragment } from "./page-graph.ts";
|
|
23
|
+
|
|
24
|
+
type FragmentWithoutPageId = Omit<RuntimeBlockFragment, "pageId">;
|
|
25
|
+
|
|
26
|
+
export function projectSurfaceBlocksToPageFragments(
|
|
27
|
+
surface: EditorSurfaceSnapshot,
|
|
28
|
+
pages: readonly DocumentPageSnapshot[],
|
|
29
|
+
): Map<number, FragmentWithoutPageId[]> {
|
|
30
|
+
const byPage = new Map<number, FragmentWithoutPageId[]>();
|
|
31
|
+
const perPageCounter = new Map<number, number>();
|
|
32
|
+
|
|
33
|
+
for (const block of surface.blocks) {
|
|
34
|
+
const pageIndex = findPageIndexForOffset(pages, block.from);
|
|
35
|
+
if (pageIndex === null) continue;
|
|
36
|
+
const orderInRegion = perPageCounter.get(pageIndex) ?? 0;
|
|
37
|
+
perPageCounter.set(pageIndex, orderInRegion + 1);
|
|
38
|
+
|
|
39
|
+
const fragment: FragmentWithoutPageId = {
|
|
40
|
+
fragmentId: `fragment-${block.blockId}`,
|
|
41
|
+
blockId: block.blockId,
|
|
42
|
+
orderInRegion,
|
|
43
|
+
regionKind: "body",
|
|
44
|
+
from: block.from,
|
|
45
|
+
to: block.to,
|
|
46
|
+
// Height is not recomputed here — the pagination engine already
|
|
47
|
+
// measured the block to assign it to this page. Chrome surfaces
|
|
48
|
+
// that need precise heights can consult layout facet measurements.
|
|
49
|
+
heightTwips: estimateBlockHeightFromSpan(block),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const existing = byPage.get(pageIndex);
|
|
53
|
+
if (existing) {
|
|
54
|
+
existing.push(fragment);
|
|
55
|
+
} else {
|
|
56
|
+
byPage.set(pageIndex, [fragment]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return byPage;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function findPageIndexForOffset(
|
|
64
|
+
pages: readonly DocumentPageSnapshot[],
|
|
65
|
+
offset: number,
|
|
66
|
+
): number | null {
|
|
67
|
+
for (let i = 0; i < pages.length; i += 1) {
|
|
68
|
+
const page = pages[i]!;
|
|
69
|
+
if (offset >= page.startOffset && offset < page.endOffset) {
|
|
70
|
+
return page.pageIndex;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Fall back to the last page for offsets that equal storySize.
|
|
74
|
+
const last = pages[pages.length - 1];
|
|
75
|
+
if (last && offset >= last.startOffset && offset <= last.endOffset) {
|
|
76
|
+
return last.pageIndex;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rough height estimate from the block's offset span. Only used as a
|
|
83
|
+
* fallback when the caller did not pre-measure. Chrome consumers that
|
|
84
|
+
* need accurate heights should read `facet.getMeasurement(blockId)`.
|
|
85
|
+
*/
|
|
86
|
+
function estimateBlockHeightFromSpan(block: SurfaceBlockSnapshot): number {
|
|
87
|
+
// 240 twips per line; approximate 80 chars per line for paragraphs.
|
|
88
|
+
const span = Math.max(0, block.to - block.from);
|
|
89
|
+
const lines = Math.max(1, Math.ceil(span / 80));
|
|
90
|
+
return lines * 240;
|
|
91
|
+
}
|