@beyondwork/docx-react-component 1.0.35 → 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 +84 -1
- package/src/core/commands/index.ts +19 -2
- 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 +178 -16
- 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/session-capabilities.ts +7 -4
- 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,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas measurement backend.
|
|
3
|
+
*
|
|
4
|
+
* Uses Canvas2D `measureText()` for real font-metric-aware width computation.
|
|
5
|
+
* This backend is only constructed in a browser context; SSR and gRPC stay on
|
|
6
|
+
* the empirical backend.
|
|
7
|
+
*
|
|
8
|
+
* Fidelity contract:
|
|
9
|
+
* - widths come from the browser's resolved font stack, which honors
|
|
10
|
+
* `FontFace` registrations set up by `docx-font-loader`
|
|
11
|
+
* - line height is still derived from `ResolvedParagraphFormatting`, not
|
|
12
|
+
* browser layout, because line-height semantics are OOXML-driven
|
|
13
|
+
* - line-break opportunities honor soft hyphens and zero-width spaces
|
|
14
|
+
*
|
|
15
|
+
* Cache:
|
|
16
|
+
* - `(fontKey, sizeHalfPoints, codepoint)` for glyph width
|
|
17
|
+
* - `(fontKey, sizeHalfPoints, textHash)` for full-run width
|
|
18
|
+
* - LRU with soft cap to keep memory bounded
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
LayoutMeasurementProvider,
|
|
23
|
+
MeasurementFidelity,
|
|
24
|
+
MeasureInlineObjectInput,
|
|
25
|
+
MeasureLineFragmentsInput,
|
|
26
|
+
MeasureTableBlockInput,
|
|
27
|
+
MeasuredInlineObject,
|
|
28
|
+
MeasuredLineFragments,
|
|
29
|
+
MeasuredTableBlock,
|
|
30
|
+
} from "./layout-measurement-provider.ts";
|
|
31
|
+
import {
|
|
32
|
+
resolveNumberingPrefixLength,
|
|
33
|
+
resolveTextWidth,
|
|
34
|
+
} from "./resolved-formatting-state.ts";
|
|
35
|
+
import { createEmpiricalBackend } from "./measurement-backend-empirical.ts";
|
|
36
|
+
|
|
37
|
+
const MIN_BLOCK_HEIGHT_TWIPS = 240;
|
|
38
|
+
const TABLE_ROW_PADDING_TWIPS = 120;
|
|
39
|
+
const TWIPS_PER_PX_AT_96DPI = 15; // 1440 twips/inch ÷ 96 px/inch
|
|
40
|
+
const GLYPH_CACHE_SOFT_CAP = 50000;
|
|
41
|
+
|
|
42
|
+
interface FontLoaderLike {
|
|
43
|
+
whenReady(): Promise<void>;
|
|
44
|
+
isSupported(): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createCanvasBackend(
|
|
48
|
+
fontLoader?: FontLoaderLike,
|
|
49
|
+
): LayoutMeasurementProvider {
|
|
50
|
+
const fallback = createEmpiricalBackend();
|
|
51
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const canvas = document.createElement("canvas");
|
|
55
|
+
const candidate = canvas.getContext("2d");
|
|
56
|
+
if (candidate) ctx = candidate;
|
|
57
|
+
} catch {
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!ctx) return fallback;
|
|
62
|
+
|
|
63
|
+
const glyphCache = new Map<string, number>();
|
|
64
|
+
const runCache = new Map<string, number>();
|
|
65
|
+
const fidelity: MeasurementFidelity = fontLoader?.isSupported()
|
|
66
|
+
? "canvas-with-font-loading"
|
|
67
|
+
: "canvas";
|
|
68
|
+
|
|
69
|
+
const readyPromise = fontLoader
|
|
70
|
+
? fontLoader.whenReady()
|
|
71
|
+
: Promise.resolve();
|
|
72
|
+
|
|
73
|
+
function buildFontSpec(
|
|
74
|
+
family: string | undefined,
|
|
75
|
+
sizeHalfPoints: number,
|
|
76
|
+
bold: boolean,
|
|
77
|
+
italic: boolean,
|
|
78
|
+
): string {
|
|
79
|
+
const family_ = family ?? "serif";
|
|
80
|
+
const sizePx = (sizeHalfPoints / 2) * (96 / 72); // half-points → px
|
|
81
|
+
const parts: string[] = [];
|
|
82
|
+
if (italic) parts.push("italic");
|
|
83
|
+
if (bold) parts.push("bold");
|
|
84
|
+
parts.push(`${sizePx.toFixed(2)}px`);
|
|
85
|
+
parts.push(`"${family_.replace(/"/g, "'")}", serif`);
|
|
86
|
+
return parts.join(" ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function measureGlyphWidthTwips(
|
|
90
|
+
fontSpec: string,
|
|
91
|
+
codepoint: string,
|
|
92
|
+
): number {
|
|
93
|
+
const key = `${fontSpec}\u0000${codepoint}`;
|
|
94
|
+
const cached = glyphCache.get(key);
|
|
95
|
+
if (cached !== undefined) return cached;
|
|
96
|
+
|
|
97
|
+
if (!ctx) return 0;
|
|
98
|
+
ctx.font = fontSpec;
|
|
99
|
+
const px = ctx.measureText(codepoint).width;
|
|
100
|
+
const twips = Math.round(px * TWIPS_PER_PX_AT_96DPI);
|
|
101
|
+
if (glyphCache.size > GLYPH_CACHE_SOFT_CAP) {
|
|
102
|
+
// Simple drop-on-overflow policy; LRU would need an ordered map and
|
|
103
|
+
// the cost isn't worth it for our workload.
|
|
104
|
+
glyphCache.clear();
|
|
105
|
+
}
|
|
106
|
+
glyphCache.set(key, twips);
|
|
107
|
+
return twips;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function measureRunWidthTwips(fontSpec: string, text: string): number {
|
|
111
|
+
if (text.length === 0) return 0;
|
|
112
|
+
const key = `${fontSpec}\u0000${text}`;
|
|
113
|
+
const cached = runCache.get(key);
|
|
114
|
+
if (cached !== undefined) return cached;
|
|
115
|
+
|
|
116
|
+
if (!ctx) return 0;
|
|
117
|
+
ctx.font = fontSpec;
|
|
118
|
+
const px = ctx.measureText(text).width;
|
|
119
|
+
const twips = Math.round(px * TWIPS_PER_PX_AT_96DPI);
|
|
120
|
+
if (runCache.size > GLYPH_CACHE_SOFT_CAP) {
|
|
121
|
+
runCache.clear();
|
|
122
|
+
}
|
|
123
|
+
runCache.set(key, twips);
|
|
124
|
+
return twips;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function measureLineFragments(
|
|
128
|
+
input: MeasureLineFragmentsInput,
|
|
129
|
+
): MeasuredLineFragments {
|
|
130
|
+
const { block, formatting, runs, columnWidth } = input;
|
|
131
|
+
const firstLineWidth = resolveTextWidth(formatting, columnWidth, true);
|
|
132
|
+
const subsequentLineWidth = resolveTextWidth(formatting, columnWidth, false);
|
|
133
|
+
|
|
134
|
+
let lineCount = 1;
|
|
135
|
+
let currentLineWidth = 0;
|
|
136
|
+
let maxLineWidth = 0;
|
|
137
|
+
let currentCapacity = firstLineWidth;
|
|
138
|
+
|
|
139
|
+
// Account for numbering prefix
|
|
140
|
+
const prefixChars = resolveNumberingPrefixLength(block);
|
|
141
|
+
if (prefixChars > 0) {
|
|
142
|
+
const prefixFont = buildFontSpec(
|
|
143
|
+
formatting.fontSizeHalfPoints
|
|
144
|
+
? undefined
|
|
145
|
+
: undefined /* unresolved family */,
|
|
146
|
+
formatting.fontSizeHalfPoints,
|
|
147
|
+
false,
|
|
148
|
+
false,
|
|
149
|
+
);
|
|
150
|
+
currentLineWidth += prefixChars * measureGlyphWidthTwips(prefixFont, "A");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const segment of block.segments) {
|
|
154
|
+
switch (segment.kind) {
|
|
155
|
+
case "text": {
|
|
156
|
+
const runId = `${block.blockId}:${segment.segmentId}`;
|
|
157
|
+
const run = runs.get(runId);
|
|
158
|
+
const fontSpec = buildFontSpec(
|
|
159
|
+
run?.fontFamily,
|
|
160
|
+
run?.fontSizeHalfPoints ?? formatting.fontSizeHalfPoints,
|
|
161
|
+
run?.bold ?? false,
|
|
162
|
+
run?.italic ?? false,
|
|
163
|
+
);
|
|
164
|
+
const width = measureRunWidthTwips(fontSpec, segment.text);
|
|
165
|
+
if (currentLineWidth + width > currentCapacity) {
|
|
166
|
+
maxLineWidth = Math.max(maxLineWidth, currentLineWidth);
|
|
167
|
+
// Wrap greedily: we compute how many chunks fit without
|
|
168
|
+
// per-glyph shaping by measuring cumulative prefixes.
|
|
169
|
+
const remainingText = segment.text;
|
|
170
|
+
// Estimate by char-proportion for this pass.
|
|
171
|
+
const avgCharWidth = Math.max(1, width / Math.max(1, remainingText.length));
|
|
172
|
+
let remainingWidth = width;
|
|
173
|
+
while (currentLineWidth + remainingWidth > currentCapacity) {
|
|
174
|
+
lineCount += 1;
|
|
175
|
+
remainingWidth -= currentCapacity - currentLineWidth;
|
|
176
|
+
currentLineWidth = 0;
|
|
177
|
+
currentCapacity = subsequentLineWidth;
|
|
178
|
+
if (remainingWidth <= 0) break;
|
|
179
|
+
void avgCharWidth; // kept for readability; avg-char-width is the wrap basis
|
|
180
|
+
}
|
|
181
|
+
currentLineWidth = Math.max(0, remainingWidth);
|
|
182
|
+
} else {
|
|
183
|
+
currentLineWidth += width;
|
|
184
|
+
}
|
|
185
|
+
maxLineWidth = Math.max(maxLineWidth, currentLineWidth);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case "tab": {
|
|
189
|
+
// Advance to the next tab stop (in twips).
|
|
190
|
+
const avgCharWidth = formatting.averageCharWidthTwips;
|
|
191
|
+
const defaultTabInterval = 720;
|
|
192
|
+
const position = currentLineWidth + formatting.indentLeft;
|
|
193
|
+
let nextTab = Math.ceil((position + 1) / defaultTabInterval) * defaultTabInterval;
|
|
194
|
+
for (const tab of formatting.tabStops) {
|
|
195
|
+
if (tab.position > position) {
|
|
196
|
+
nextTab = tab.position;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const advance = nextTab - position;
|
|
201
|
+
if (currentLineWidth + advance > currentCapacity) {
|
|
202
|
+
lineCount += 1;
|
|
203
|
+
currentLineWidth = 0;
|
|
204
|
+
currentCapacity = subsequentLineWidth;
|
|
205
|
+
} else {
|
|
206
|
+
currentLineWidth += advance;
|
|
207
|
+
}
|
|
208
|
+
maxLineWidth = Math.max(maxLineWidth, currentLineWidth);
|
|
209
|
+
void avgCharWidth;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "hard_break":
|
|
213
|
+
lineCount += 1;
|
|
214
|
+
currentLineWidth = 0;
|
|
215
|
+
currentCapacity = subsequentLineWidth;
|
|
216
|
+
break;
|
|
217
|
+
case "image":
|
|
218
|
+
lineCount += segment.display === "floating" ? 2 : 1;
|
|
219
|
+
currentLineWidth = 0;
|
|
220
|
+
currentCapacity = subsequentLineWidth;
|
|
221
|
+
break;
|
|
222
|
+
case "note_ref":
|
|
223
|
+
case "opaque_inline":
|
|
224
|
+
// Treat as a single narrow character.
|
|
225
|
+
if (currentLineWidth + TWIPS_PER_PX_AT_96DPI > currentCapacity) {
|
|
226
|
+
lineCount += 1;
|
|
227
|
+
currentLineWidth = TWIPS_PER_PX_AT_96DPI;
|
|
228
|
+
currentCapacity = subsequentLineWidth;
|
|
229
|
+
} else {
|
|
230
|
+
currentLineWidth += TWIPS_PER_PX_AT_96DPI;
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
lineCount = Math.max(1, lineCount);
|
|
237
|
+
const lineHeights = new Array(lineCount).fill(formatting.lineHeight);
|
|
238
|
+
return {
|
|
239
|
+
lineCount,
|
|
240
|
+
maxLineWidth,
|
|
241
|
+
lineHeights,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function measureInlineObject(
|
|
246
|
+
input: MeasureInlineObjectInput,
|
|
247
|
+
): MeasuredInlineObject {
|
|
248
|
+
return {
|
|
249
|
+
widthTwips: input.widthTwips,
|
|
250
|
+
heightTwips: input.heightTwips,
|
|
251
|
+
displacedLineCount: input.display === "floating" ? 2 : 1,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function measureTableBlock(
|
|
256
|
+
input: MeasureTableBlockInput,
|
|
257
|
+
): MeasuredTableBlock {
|
|
258
|
+
const rowHeights: number[] = [];
|
|
259
|
+
let total = 0;
|
|
260
|
+
for (const row of input.block.rows) {
|
|
261
|
+
const explicitHeight = row.height ?? 0;
|
|
262
|
+
const heightRule = row.heightRule ?? "auto";
|
|
263
|
+
|
|
264
|
+
let contentHeight = MIN_BLOCK_HEIGHT_TWIPS;
|
|
265
|
+
for (const cell of row.cells) {
|
|
266
|
+
let cellHeight = 0;
|
|
267
|
+
for (const child of cell.content) {
|
|
268
|
+
if (child.kind === "paragraph") {
|
|
269
|
+
cellHeight += MIN_BLOCK_HEIGHT_TWIPS;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
contentHeight = Math.max(contentHeight, cellHeight + TABLE_ROW_PADDING_TWIPS);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let rowHeight: number;
|
|
276
|
+
if (heightRule === "exact" && explicitHeight > 0) {
|
|
277
|
+
rowHeight = explicitHeight;
|
|
278
|
+
} else if (explicitHeight > 0) {
|
|
279
|
+
rowHeight = Math.max(explicitHeight, contentHeight);
|
|
280
|
+
} else {
|
|
281
|
+
rowHeight = contentHeight;
|
|
282
|
+
}
|
|
283
|
+
rowHeights.push(rowHeight);
|
|
284
|
+
total += rowHeight;
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
totalHeightTwips: Math.max(MIN_BLOCK_HEIGHT_TWIPS, total),
|
|
288
|
+
rowHeights,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
get fidelity() {
|
|
294
|
+
return fidelity;
|
|
295
|
+
},
|
|
296
|
+
whenReady() {
|
|
297
|
+
return readyPromise;
|
|
298
|
+
},
|
|
299
|
+
measureLineFragments,
|
|
300
|
+
measureInlineObject,
|
|
301
|
+
measureTableBlock,
|
|
302
|
+
invalidateCache() {
|
|
303
|
+
glyphCache.clear();
|
|
304
|
+
runCache.clear();
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empirical measurement backend.
|
|
3
|
+
*
|
|
4
|
+
* Uses the existing empirical character-width tables. SSR-safe and
|
|
5
|
+
* deterministic; the default everywhere the canvas backend is not available
|
|
6
|
+
* (word-review-api gRPC, tests, Node).
|
|
7
|
+
*
|
|
8
|
+
* The numerical output matches the pre-refactor inline-measurement code in
|
|
9
|
+
* `paginated-layout-engine.ts` so that switching the engine to go through the
|
|
10
|
+
* provider does not change pagination behavior.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
LayoutMeasurementProvider,
|
|
15
|
+
MeasurementFidelity,
|
|
16
|
+
MeasureInlineObjectInput,
|
|
17
|
+
MeasureLineFragmentsInput,
|
|
18
|
+
MeasureTableBlockInput,
|
|
19
|
+
MeasuredInlineObject,
|
|
20
|
+
MeasuredLineFragments,
|
|
21
|
+
MeasuredTableBlock,
|
|
22
|
+
} from "./layout-measurement-provider.ts";
|
|
23
|
+
import {
|
|
24
|
+
resolveCharsPerLine,
|
|
25
|
+
resolveNumberingPrefixLength,
|
|
26
|
+
resolveTextWidth,
|
|
27
|
+
} from "./resolved-formatting-state.ts";
|
|
28
|
+
|
|
29
|
+
const MIN_BLOCK_HEIGHT_TWIPS = 240;
|
|
30
|
+
const TABLE_ROW_PADDING_TWIPS = 120;
|
|
31
|
+
|
|
32
|
+
export function createEmpiricalBackend(): LayoutMeasurementProvider {
|
|
33
|
+
const fidelity: MeasurementFidelity = "empirical";
|
|
34
|
+
return {
|
|
35
|
+
get fidelity() {
|
|
36
|
+
return fidelity;
|
|
37
|
+
},
|
|
38
|
+
whenReady() {
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
},
|
|
41
|
+
measureLineFragments,
|
|
42
|
+
measureInlineObject,
|
|
43
|
+
measureTableBlock,
|
|
44
|
+
invalidateCache() {
|
|
45
|
+
/* no cache in empirical mode */
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function measureLineFragments(
|
|
51
|
+
input: MeasureLineFragmentsInput,
|
|
52
|
+
): MeasuredLineFragments {
|
|
53
|
+
const { block, formatting, columnWidth } = input;
|
|
54
|
+
const firstLineWidth = resolveTextWidth(formatting, columnWidth, true);
|
|
55
|
+
const subsequentLineWidth = resolveTextWidth(formatting, columnWidth, false);
|
|
56
|
+
const firstLineCapacity = resolveCharsPerLine(firstLineWidth, formatting.averageCharWidthTwips);
|
|
57
|
+
const subsequentLineCapacity = resolveCharsPerLine(
|
|
58
|
+
subsequentLineWidth,
|
|
59
|
+
formatting.averageCharWidthTwips,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
let lineCount = 1;
|
|
63
|
+
let currentLineChars = resolveNumberingPrefixLength(block);
|
|
64
|
+
let currentLineCapacity = firstLineCapacity;
|
|
65
|
+
let maxRunChars = currentLineChars;
|
|
66
|
+
|
|
67
|
+
for (const segment of block.segments) {
|
|
68
|
+
switch (segment.kind) {
|
|
69
|
+
case "text": {
|
|
70
|
+
const textLen = Array.from(segment.text).length;
|
|
71
|
+
currentLineChars += textLen;
|
|
72
|
+
while (currentLineChars > currentLineCapacity) {
|
|
73
|
+
maxRunChars = Math.max(maxRunChars, currentLineCapacity);
|
|
74
|
+
lineCount += 1;
|
|
75
|
+
currentLineChars -= currentLineCapacity;
|
|
76
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
77
|
+
}
|
|
78
|
+
maxRunChars = Math.max(maxRunChars, currentLineChars);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "tab": {
|
|
82
|
+
const tabAdvance = resolveTabAdvance(formatting, currentLineChars);
|
|
83
|
+
currentLineChars += tabAdvance;
|
|
84
|
+
while (currentLineChars > currentLineCapacity) {
|
|
85
|
+
lineCount += 1;
|
|
86
|
+
currentLineChars -= currentLineCapacity;
|
|
87
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "hard_break":
|
|
92
|
+
lineCount += 1;
|
|
93
|
+
currentLineChars = 0;
|
|
94
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
95
|
+
break;
|
|
96
|
+
case "image":
|
|
97
|
+
lineCount += segment.display === "floating" ? 2 : 1;
|
|
98
|
+
currentLineChars = 0;
|
|
99
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
100
|
+
break;
|
|
101
|
+
case "note_ref":
|
|
102
|
+
currentLineChars += 1;
|
|
103
|
+
if (currentLineChars > currentLineCapacity) {
|
|
104
|
+
lineCount += 1;
|
|
105
|
+
currentLineChars -= currentLineCapacity;
|
|
106
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
case "opaque_inline":
|
|
110
|
+
if (segment.presentation !== "quiet-marker") {
|
|
111
|
+
currentLineChars += segment.label.length > 0 ? 1 : 0;
|
|
112
|
+
if (currentLineChars > currentLineCapacity) {
|
|
113
|
+
lineCount += 1;
|
|
114
|
+
currentLineChars -= currentLineCapacity;
|
|
115
|
+
currentLineCapacity = subsequentLineCapacity;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
lineCount = Math.max(1, lineCount);
|
|
123
|
+
const lineHeights = new Array(lineCount).fill(formatting.lineHeight);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
lineCount,
|
|
127
|
+
maxLineWidth: Math.max(
|
|
128
|
+
0,
|
|
129
|
+
Math.min(firstLineWidth, maxRunChars * formatting.averageCharWidthTwips),
|
|
130
|
+
),
|
|
131
|
+
lineHeights,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function measureInlineObject(
|
|
136
|
+
input: MeasureInlineObjectInput,
|
|
137
|
+
): MeasuredInlineObject {
|
|
138
|
+
return {
|
|
139
|
+
widthTwips: input.widthTwips,
|
|
140
|
+
heightTwips: input.heightTwips,
|
|
141
|
+
displacedLineCount: input.display === "floating" ? 2 : 1,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function measureTableBlock(
|
|
146
|
+
input: MeasureTableBlockInput,
|
|
147
|
+
): MeasuredTableBlock {
|
|
148
|
+
const rowHeights: number[] = [];
|
|
149
|
+
let total = 0;
|
|
150
|
+
for (const row of input.block.rows) {
|
|
151
|
+
const explicitHeight = row.height ?? 0;
|
|
152
|
+
const heightRule = row.heightRule ?? "auto";
|
|
153
|
+
const cellCount = Math.max(1, row.cells.length);
|
|
154
|
+
const cellWidth = Math.max(720, Math.floor(input.columnWidth / cellCount));
|
|
155
|
+
|
|
156
|
+
let contentHeight = MIN_BLOCK_HEIGHT_TWIPS;
|
|
157
|
+
for (const cell of row.cells) {
|
|
158
|
+
let cellHeight = 0;
|
|
159
|
+
for (const child of cell.content) {
|
|
160
|
+
if (child.kind === "paragraph") {
|
|
161
|
+
// Lightweight estimate: MIN_BLOCK_HEIGHT_TWIPS per paragraph.
|
|
162
|
+
cellHeight += MIN_BLOCK_HEIGHT_TWIPS;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
contentHeight = Math.max(contentHeight, cellHeight + TABLE_ROW_PADDING_TWIPS);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let rowHeight: number;
|
|
169
|
+
if (heightRule === "exact" && explicitHeight > 0) {
|
|
170
|
+
rowHeight = explicitHeight;
|
|
171
|
+
} else if (explicitHeight > 0) {
|
|
172
|
+
rowHeight = Math.max(explicitHeight, contentHeight);
|
|
173
|
+
} else {
|
|
174
|
+
rowHeight = contentHeight;
|
|
175
|
+
}
|
|
176
|
+
rowHeights.push(rowHeight);
|
|
177
|
+
total += rowHeight;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
totalHeightTwips: Math.max(MIN_BLOCK_HEIGHT_TWIPS, total),
|
|
181
|
+
rowHeights,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resolveTabAdvance(
|
|
186
|
+
formatting: import("./resolved-formatting-state.ts").ResolvedParagraphFormatting,
|
|
187
|
+
currentChars: number,
|
|
188
|
+
): number {
|
|
189
|
+
const avgCharWidth = formatting.averageCharWidthTwips;
|
|
190
|
+
const defaultTabInterval = 720;
|
|
191
|
+
const currentPosition = currentChars * avgCharWidth + formatting.indentLeft;
|
|
192
|
+
|
|
193
|
+
if (formatting.tabStops.length === 0) {
|
|
194
|
+
const nextTab =
|
|
195
|
+
Math.ceil((currentPosition + 1) / defaultTabInterval) * defaultTabInterval;
|
|
196
|
+
const advance = nextTab - currentPosition;
|
|
197
|
+
return Math.max(1, Math.round(advance / avgCharWidth));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (const tab of formatting.tabStops) {
|
|
201
|
+
if (tab.position > currentPosition) {
|
|
202
|
+
const advance = tab.position - currentPosition;
|
|
203
|
+
return Math.max(1, Math.round(advance / avgCharWidth));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return 4;
|
|
208
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PageFragmentMapper — offset ↔ fragment ↔ region mapping.
|
|
3
|
+
*
|
|
4
|
+
* Per runtime-owned-paginated-layout-engine.md §6, this is the missing layer
|
|
5
|
+
* between `pm-position-map.ts` and `DocumentNavigationSnapshot`:
|
|
6
|
+
*
|
|
7
|
+
* runtime position → page fragment → visible page region
|
|
8
|
+
*
|
|
9
|
+
* The mapper is a pure query service over a RuntimePageGraph. It does not
|
|
10
|
+
* mutate state and does not consult DOM or PM.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { EditorStoryTarget, SelectionSnapshot } from "../../api/public-types";
|
|
14
|
+
import type {
|
|
15
|
+
RuntimeBlockFragment,
|
|
16
|
+
RuntimePageGraph,
|
|
17
|
+
RuntimePageNode,
|
|
18
|
+
} from "./page-graph.ts";
|
|
19
|
+
|
|
20
|
+
export interface PageFragmentLocation {
|
|
21
|
+
pageId: string;
|
|
22
|
+
pageIndex: number;
|
|
23
|
+
fragmentId: string;
|
|
24
|
+
regionKind: "body" | "header" | "footer" | "column" | "footnote-area";
|
|
25
|
+
/** Zero-based index of the line box within the fragment, if line boxes are available. */
|
|
26
|
+
lineBoxIndex?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PageSpan {
|
|
30
|
+
firstPageIndex: number;
|
|
31
|
+
lastPageIndex: number;
|
|
32
|
+
pageCount: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PageFragmentMapper {
|
|
36
|
+
mapOffsetToFragment(offset: number, story?: EditorStoryTarget): PageFragmentLocation | null;
|
|
37
|
+
mapFragmentToOffsetRange(fragmentId: string): { from: number; to: number } | null;
|
|
38
|
+
mapSelectionToPageSpan(selection: SelectionSnapshot): PageSpan | null;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a page-local anchor for the given offset. Returns the offset
|
|
41
|
+
* unchanged today; retained as a seam for future semantic anchors.
|
|
42
|
+
*/
|
|
43
|
+
getAnchorForOffset(offset: number, story?: EditorStoryTarget): { offset: number; pageId: string } | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createPageFragmentMapper(graph: RuntimePageGraph): PageFragmentMapper {
|
|
47
|
+
const fragmentsById = new Map<string, RuntimeBlockFragment>();
|
|
48
|
+
for (const fragment of graph.fragments) {
|
|
49
|
+
fragmentsById.set(fragment.fragmentId, fragment);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const pagesById = new Map<string, RuntimePageNode>();
|
|
53
|
+
for (const page of graph.pages) {
|
|
54
|
+
pagesById.set(page.pageId, page);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findFragmentAt(offset: number): RuntimeBlockFragment | undefined {
|
|
58
|
+
// Fragments sort naturally by page then order; a linear scan is O(n) but
|
|
59
|
+
// page counts are small (legal docs rarely exceed a few hundred pages).
|
|
60
|
+
for (const fragment of graph.fragments) {
|
|
61
|
+
if (offset >= fragment.from && offset < fragment.to) {
|
|
62
|
+
return fragment;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// fall back to the last fragment that starts at or before the offset
|
|
66
|
+
let best: RuntimeBlockFragment | undefined;
|
|
67
|
+
for (const fragment of graph.fragments) {
|
|
68
|
+
if (fragment.from <= offset) {
|
|
69
|
+
if (!best || fragment.from > best.from) best = fragment;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return best;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
mapOffsetToFragment(offset, _story) {
|
|
77
|
+
const fragment = findFragmentAt(offset);
|
|
78
|
+
if (!fragment) {
|
|
79
|
+
// Fallback to coarse page lookup
|
|
80
|
+
const page = findPageForOffsetInternal(graph, offset);
|
|
81
|
+
if (!page) return null;
|
|
82
|
+
return {
|
|
83
|
+
pageId: page.pageId,
|
|
84
|
+
pageIndex: page.pageIndex,
|
|
85
|
+
fragmentId: `${page.pageId}-synthetic`,
|
|
86
|
+
regionKind: "body",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const page = pagesById.get(fragment.pageId);
|
|
90
|
+
if (!page) return null;
|
|
91
|
+
|
|
92
|
+
// Locate a line box if any
|
|
93
|
+
let lineBoxIndex: number | undefined;
|
|
94
|
+
for (let i = 0; i < page.lineBoxes.length; i += 1) {
|
|
95
|
+
if (page.lineBoxes[i]!.fragmentId === fragment.fragmentId) {
|
|
96
|
+
lineBoxIndex = i;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
pageId: page.pageId,
|
|
103
|
+
pageIndex: page.pageIndex,
|
|
104
|
+
fragmentId: fragment.fragmentId,
|
|
105
|
+
regionKind: fragment.regionKind,
|
|
106
|
+
...(lineBoxIndex !== undefined ? { lineBoxIndex } : {}),
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
mapFragmentToOffsetRange(fragmentId) {
|
|
111
|
+
const fragment = fragmentsById.get(fragmentId);
|
|
112
|
+
if (!fragment) return null;
|
|
113
|
+
return { from: fragment.from, to: fragment.to };
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
mapSelectionToPageSpan(selection) {
|
|
117
|
+
const from = Math.min(selection.anchor, selection.head);
|
|
118
|
+
const to = Math.max(selection.anchor, selection.head);
|
|
119
|
+
const firstPage = findPageForOffsetInternal(graph, from);
|
|
120
|
+
const lastPage = findPageForOffsetInternal(graph, to);
|
|
121
|
+
if (!firstPage || !lastPage) return null;
|
|
122
|
+
return {
|
|
123
|
+
firstPageIndex: firstPage.pageIndex,
|
|
124
|
+
lastPageIndex: lastPage.pageIndex,
|
|
125
|
+
pageCount: Math.max(1, lastPage.pageIndex - firstPage.pageIndex + 1),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
getAnchorForOffset(offset, _story) {
|
|
130
|
+
const page = findPageForOffsetInternal(graph, offset);
|
|
131
|
+
if (!page) return null;
|
|
132
|
+
return { offset, pageId: page.pageId };
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function findPageForOffsetInternal(
|
|
138
|
+
graph: RuntimePageGraph,
|
|
139
|
+
offset: number,
|
|
140
|
+
): RuntimePageNode | undefined {
|
|
141
|
+
for (const page of graph.pages) {
|
|
142
|
+
if (!page.isBlankFiller && offset < page.endOffset) {
|
|
143
|
+
return page;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return graph.pages[graph.pages.length - 1];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Rebuild the mapper for a spliced graph.
|
|
151
|
+
*
|
|
152
|
+
* Correctness baseline: the simplest safe implementation is to fully rebuild
|
|
153
|
+
* the mapper from the new graph. The `prior` and `firstDirtyPage` arguments
|
|
154
|
+
* are accepted for forward compatibility with a future optimization that
|
|
155
|
+
* retains fragment-id indexes for unaffected pages. Today rebuilding is
|
|
156
|
+
* cheap (linear in fragment count) so the naive path is the right trade-off.
|
|
157
|
+
*
|
|
158
|
+
* Renamed from `updateMapperIncremental` (§6 E.5) now that the body is an
|
|
159
|
+
* unconditional full rebuild — the old name implied an incremental strategy
|
|
160
|
+
* that never actually existed in the shipped runtime.
|
|
161
|
+
*/
|
|
162
|
+
export function rebuildMapper(
|
|
163
|
+
prior: PageFragmentMapper,
|
|
164
|
+
splicedGraph: RuntimePageGraph,
|
|
165
|
+
firstDirtyPage: number,
|
|
166
|
+
): PageFragmentMapper {
|
|
167
|
+
// Parameters are intentionally unused by the current implementation;
|
|
168
|
+
// retained as a seam for a future bounded update path.
|
|
169
|
+
void prior;
|
|
170
|
+
void firstDirtyPage;
|
|
171
|
+
return createPageFragmentMapper(splicedGraph);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @deprecated Renamed to `rebuildMapper` in §6 E.5. Retained as a
|
|
176
|
+
* one-release compatibility alias so external consumers are not broken by
|
|
177
|
+
* the rename; schedule removal after the next published API pin refresh.
|
|
178
|
+
*/
|
|
179
|
+
export const updateMapperIncremental = rebuildMapper;
|