@beyondwork/docx-react-component 1.0.36 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +83 -0
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +173 -11
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/surface-projection.ts +1 -0
  44. package/src/runtime/text-ack-range.ts +49 -0
  45. package/src/ui/WordReviewEditor.tsx +15 -0
  46. package/src/ui/editor-runtime-boundary.ts +10 -1
  47. package/src/ui/editor-surface-controller.tsx +3 -0
  48. package/src/ui/headless/chrome-registry.ts +235 -0
  49. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  50. package/src/ui/headless/selection-tool-context.ts +2 -0
  51. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  52. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  53. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  54. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  55. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  56. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  57. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  58. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  61. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  62. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  63. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  64. 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;