@beyondwork/docx-react-component 1.0.102 → 1.0.104

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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +63 -1
  3. package/src/api/v3/_runtime-handle.ts +2 -0
  4. package/src/api/v3/ai/outline.ts +2 -7
  5. package/src/api/v3/runtime/geometry.ts +79 -0
  6. package/src/core/commands/formatting-commands.ts +8 -7
  7. package/src/core/commands/paragraph-layout-commands.ts +11 -10
  8. package/src/core/commands/section-layout-commands.ts +7 -6
  9. package/src/core/commands/style-commands.ts +3 -2
  10. package/src/io/normalize/normalize-text.ts +6 -5
  11. package/src/io/ooxml/parse-anchor.ts +15 -15
  12. package/src/io/ooxml/parse-drawing.ts +103 -5
  13. package/src/io/ooxml/parse-fields.ts +43 -21
  14. package/src/io/ooxml/parse-font-table.ts +2 -1
  15. package/src/io/ooxml/parse-footnotes.ts +3 -2
  16. package/src/io/ooxml/parse-headers-footers.ts +7 -6
  17. package/src/io/ooxml/parse-main-document.ts +41 -40
  18. package/src/io/ooxml/parse-numbering.ts +3 -2
  19. package/src/io/ooxml/parse-object.ts +6 -6
  20. package/src/io/ooxml/parse-paragraph-formatting.ts +12 -11
  21. package/src/io/ooxml/parse-picture.ts +16 -16
  22. package/src/io/ooxml/parse-run-formatting.ts +11 -10
  23. package/src/io/ooxml/parse-settings.ts +2 -1
  24. package/src/io/ooxml/parse-shapes.ts +148 -17
  25. package/src/io/ooxml/parse-styles.ts +16 -16
  26. package/src/io/ooxml/parse-theme.ts +5 -4
  27. package/src/model/canonical-document.ts +869 -836
  28. package/src/model/canonical-layout-inputs.ts +979 -0
  29. package/src/model/layout/index.ts +6 -0
  30. package/src/model/layout/page-graph-types.ts +61 -0
  31. package/src/model/layout/runtime-page-graph-types.ts +10 -0
  32. package/src/runtime/collab/runtime-collab-sync.ts +3 -3
  33. package/src/runtime/debug/build-debug-inspector-snapshot.ts +17 -4
  34. package/src/runtime/document-runtime.ts +30 -14
  35. package/src/runtime/event-refresh-hints.ts +3 -0
  36. package/src/runtime/formatting/document-lookup.ts +3 -2
  37. package/src/runtime/formatting/formatting-context.ts +176 -34
  38. package/src/runtime/formatting/index.ts +20 -0
  39. package/src/runtime/formatting/layout-inputs.ts +320 -0
  40. package/src/runtime/formatting/numbering/geometry.ts +13 -12
  41. package/src/runtime/formatting/style-cascade.ts +2 -1
  42. package/src/runtime/formatting/table-style-resolver.ts +8 -7
  43. package/src/runtime/geometry/caret-geometry.ts +82 -10
  44. package/src/runtime/geometry/geometry-facet.ts +36 -0
  45. package/src/runtime/geometry/geometry-index.ts +891 -0
  46. package/src/runtime/geometry/geometry-types.ts +221 -1
  47. package/src/runtime/geometry/index.ts +26 -0
  48. package/src/runtime/geometry/inert-geometry-facet.ts +3 -0
  49. package/src/runtime/geometry/replacement-envelope.ts +41 -2
  50. package/src/runtime/layout/layout-engine-version.ts +16 -1
  51. package/src/runtime/layout/page-graph.ts +191 -1
  52. package/src/runtime/prerender/graph-canonicalize.ts +30 -0
  53. package/src/runtime/surface-projection.ts +74 -39
  54. package/src/runtime/workflow/coordinator.ts +57 -11
  55. package/src/session/import/normalize.ts +2 -1
  56. package/src/session/import/source-package-evidence.ts +612 -1
  57. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +3 -0
@@ -0,0 +1,320 @@
1
+ import type {
2
+ CanonicalParagraphFormatting,
3
+ CanonicalRunFormatting,
4
+ FieldFamily,
5
+ FieldRefreshStatus,
6
+ FieldRegistry,
7
+ FieldRegistryEntry,
8
+ TabStop,
9
+ } from "../../model/canonical-document.ts";
10
+ import {
11
+ MAIN_STORY_KEY,
12
+ type CanonicalFieldRegionIdentity,
13
+ } from "../../model/canonical-layout-inputs.ts";
14
+ import type { ResolvedField } from "./field/resolver.ts";
15
+ import type {
16
+ EffectiveParagraphFormatting,
17
+ EffectiveRunFormatting,
18
+ EffectiveTableFormatting,
19
+ RevisionDisplayFlags,
20
+ } from "./formatting-types.ts";
21
+ import type { NumberingPrefixResult } from "./numbering/prefix.ts";
22
+
23
+ export type LayoutMarkerSuffix = "tab" | "space" | "none";
24
+
25
+ export interface LayoutTabStopInput {
26
+ readonly positionTwips: number;
27
+ readonly align: TabStop["align"];
28
+ readonly leader?: TabStop["leader"];
29
+ readonly source: "paragraph" | "numbering" | "toc";
30
+ }
31
+
32
+ export interface NumberingLayoutInput {
33
+ readonly markerText: string | null;
34
+ readonly markerRunFormatting?: CanonicalRunFormatting;
35
+ readonly markerSuffix: LayoutMarkerSuffix;
36
+ readonly markerLaneStartTwips?: number;
37
+ readonly markerLaneWidthTwips?: number;
38
+ readonly textColumnStartTwips?: number;
39
+ readonly hangingTwips?: number;
40
+ readonly associatedTabStops: readonly LayoutTabStopInput[];
41
+ readonly level: number;
42
+ readonly format: string;
43
+ readonly startAt: number;
44
+ readonly isLegalNumbering?: boolean;
45
+ readonly pictureBulletMediaId?: string;
46
+ }
47
+
48
+ export interface FieldLayoutInput {
49
+ readonly fieldId: string;
50
+ readonly regionId: string;
51
+ readonly regionKind: "field" | "toc-region";
52
+ readonly storyKey: string;
53
+ readonly fieldIndex: number;
54
+ readonly paragraphIndex: number;
55
+ readonly family: FieldFamily;
56
+ readonly instruction: string;
57
+ readonly cachedText: string;
58
+ readonly switches?: FieldRegistryEntry["switches"];
59
+ readonly targetBookmark?: string;
60
+ readonly refreshState: FieldRefreshStatus | "unresolved";
61
+ readonly resolvedText?: string;
62
+ readonly asHyperlink?: boolean;
63
+ readonly tocRegionId?: string;
64
+ readonly tocEntryCount?: number;
65
+ }
66
+
67
+ export interface RevisionLayoutPosture {
68
+ readonly mode: "clean" | "simple" | "all" | "original" | "no-markup";
69
+ /**
70
+ * True only when the active revision display changes measured text
71
+ * for the paragraph. Paint-only redline posture is compositor data,
72
+ * not an L04 measurement input.
73
+ */
74
+ readonly affectsMeasuredText: boolean;
75
+ readonly hiddenRevisionIds: readonly string[];
76
+ readonly visibleRevisionIds: readonly string[];
77
+ }
78
+
79
+ export interface EffectiveLayoutFormatting {
80
+ readonly paragraph?: EffectiveParagraphFormatting;
81
+ readonly runs: readonly EffectiveRunFormatting[];
82
+ readonly table?: EffectiveTableFormatting;
83
+ readonly cell?: {
84
+ readonly paragraph: CanonicalParagraphFormatting;
85
+ readonly run: CanonicalRunFormatting;
86
+ };
87
+ readonly numbering?: NumberingLayoutInput;
88
+ readonly tabs: readonly LayoutTabStopInput[];
89
+ readonly compatFlags: readonly string[];
90
+ readonly revisionPosture?: RevisionLayoutPosture;
91
+ readonly structuralHash: string;
92
+ }
93
+
94
+ export function normalizeNumberingMarkerSuffix(
95
+ suffix: NumberingPrefixResult["suffix"] | undefined,
96
+ ): LayoutMarkerSuffix {
97
+ if (suffix === "tab" || suffix === "space") return suffix;
98
+ return "none";
99
+ }
100
+
101
+ export function toLayoutTabStops(
102
+ tabStops: readonly TabStop[] | undefined,
103
+ source: LayoutTabStopInput["source"],
104
+ ): readonly LayoutTabStopInput[] {
105
+ if (!tabStops || tabStops.length === 0) return [];
106
+ return tabStops
107
+ .map((tabStop) => ({
108
+ positionTwips: tabStop.position,
109
+ align: tabStop.align,
110
+ ...(tabStop.leader ? { leader: tabStop.leader } : {}),
111
+ source,
112
+ }))
113
+ .sort((a, b) => a.positionTwips - b.positionTwips);
114
+ }
115
+
116
+ export function mergeLayoutTabStops(
117
+ ...groups: readonly (readonly LayoutTabStopInput[] | undefined)[]
118
+ ): readonly LayoutTabStopInput[] {
119
+ const byKey = new Map<string, LayoutTabStopInput>();
120
+ for (const group of groups) {
121
+ for (const tabStop of group ?? []) {
122
+ const key = [
123
+ tabStop.source,
124
+ tabStop.positionTwips,
125
+ tabStop.align,
126
+ tabStop.leader ?? "",
127
+ ].join(":");
128
+ byKey.set(key, tabStop);
129
+ }
130
+ }
131
+ return [...byKey.values()].sort((a, b) => {
132
+ if (a.positionTwips !== b.positionTwips) return a.positionTwips - b.positionTwips;
133
+ return a.source.localeCompare(b.source);
134
+ });
135
+ }
136
+
137
+ export function toNumberingLayoutInput(
138
+ numbering: NumberingPrefixResult | null | undefined,
139
+ ): NumberingLayoutInput | undefined {
140
+ if (!numbering) return undefined;
141
+ const markerLane = numbering.geometry.markerLane;
142
+ const textColumn = numbering.geometry.textColumn;
143
+ const hangingTwips =
144
+ textColumn?.hanging ??
145
+ numbering.geometry.indentation?.hanging ??
146
+ (typeof numbering.geometry.indentation?.firstLine === "number" &&
147
+ numbering.geometry.indentation.firstLine < 0
148
+ ? Math.abs(numbering.geometry.indentation.firstLine)
149
+ : undefined);
150
+
151
+ return {
152
+ markerText: numbering.text,
153
+ ...(numbering.markerRunProperties
154
+ ? { markerRunFormatting: numbering.markerRunProperties }
155
+ : {}),
156
+ markerSuffix: normalizeNumberingMarkerSuffix(numbering.suffix),
157
+ ...(markerLane ? { markerLaneStartTwips: markerLane.start } : {}),
158
+ ...(markerLane ? { markerLaneWidthTwips: markerLane.width } : {}),
159
+ ...(textColumn ? { textColumnStartTwips: textColumn.start } : {}),
160
+ ...(hangingTwips !== undefined ? { hangingTwips } : {}),
161
+ associatedTabStops: toLayoutTabStops(numbering.geometry.tabStops, "numbering"),
162
+ level: numbering.level,
163
+ format: numbering.format,
164
+ startAt: numbering.startAt,
165
+ ...(numbering.isLegalNumbering ? { isLegalNumbering: true } : {}),
166
+ ...(numbering.picBulletMediaId
167
+ ? { pictureBulletMediaId: numbering.picBulletMediaId }
168
+ : {}),
169
+ };
170
+ }
171
+
172
+ export function toFieldLayoutInput(
173
+ entry: FieldRegistryEntry,
174
+ resolved?: ResolvedField,
175
+ region?: CanonicalFieldRegionIdentity,
176
+ ): FieldLayoutInput {
177
+ return {
178
+ fieldId: `field-${entry.fieldIndex}`,
179
+ regionId: region?.regionId ?? `field:${entry.fieldIndex}`,
180
+ regionKind: region?.kind ?? "field",
181
+ storyKey: region?.storyKey ?? entry.storyKey ?? MAIN_STORY_KEY,
182
+ fieldIndex: entry.fieldIndex,
183
+ paragraphIndex: region?.paragraphIndex ?? entry.paragraphIndex,
184
+ family: entry.fieldFamily,
185
+ instruction: entry.instruction,
186
+ cachedText: entry.displayText,
187
+ ...(entry.switches ? { switches: entry.switches } : {}),
188
+ ...(entry.fieldTarget ? { targetBookmark: entry.fieldTarget } : {}),
189
+ refreshState: resolved?.refreshStatus ?? entry.refreshStatus ?? "unresolved",
190
+ ...(resolved?.displayText !== undefined ? { resolvedText: resolved.displayText } : {}),
191
+ ...(resolved?.asHyperlink ? { asHyperlink: true } : {}),
192
+ };
193
+ }
194
+
195
+ export function collectFieldLayoutInputs(
196
+ registry: FieldRegistry | undefined,
197
+ resolve?: (entry: FieldRegistryEntry) => ResolvedField | undefined,
198
+ regions: readonly CanonicalFieldRegionIdentity[] = [],
199
+ ): readonly FieldLayoutInput[] {
200
+ if (!registry) return [];
201
+ const inputs: FieldLayoutInput[] = [];
202
+ const entries = [...registry.supported, ...registry.preserveOnly];
203
+ const fieldRegions = new Map(
204
+ regions
205
+ .filter((region) => region.kind === "field")
206
+ .map((region) => [region.fieldIndex, region]),
207
+ );
208
+ const tocRegions = new Map(
209
+ regions
210
+ .filter((region) => region.kind === "toc-region" && region.tocId !== undefined)
211
+ .map((region) => [region.tocId!, region]),
212
+ );
213
+ for (const entry of entries) {
214
+ inputs.push(toFieldLayoutInput(entry, resolve?.(entry), fieldRegions.get(entry.fieldIndex)));
215
+ }
216
+ for (const region of registry.tocRegions ?? []) {
217
+ const source = registry.supported.find(
218
+ (entry) => entry.fieldIndex === region.sourceFieldIndex,
219
+ );
220
+ if (!source) continue;
221
+ const identity = tocRegions.get(region.tocId);
222
+ inputs.push({
223
+ ...toFieldLayoutInput(source, resolve?.(source), identity),
224
+ fieldId: `toc-${region.tocId}`,
225
+ family: "TOC",
226
+ instruction: region.instruction.raw,
227
+ cachedText: region.cachedEntries.map((entry) => entry.displayText).join("\n"),
228
+ refreshState: region.status === "current" ? "current" : "stale",
229
+ tocRegionId: region.tocId,
230
+ tocEntryCount: region.cachedEntries.length + region.generatedEntries.length,
231
+ });
232
+ }
233
+ return inputs;
234
+ }
235
+
236
+ export function buildRevisionLayoutPosture(
237
+ mode: RevisionLayoutPosture["mode"],
238
+ displays: readonly RevisionDisplayFlags[] = [],
239
+ ): RevisionLayoutPosture {
240
+ const hiddenRevisionIds = displays
241
+ .filter((display) => display.hidden === true)
242
+ .map((display) => display.revisionId);
243
+ const visibleRevisionIds = displays
244
+ .filter((display) => display.hidden !== true)
245
+ .map((display) => display.revisionId);
246
+ return {
247
+ mode,
248
+ affectsMeasuredText: hiddenRevisionIds.length > 0,
249
+ hiddenRevisionIds,
250
+ visibleRevisionIds,
251
+ };
252
+ }
253
+
254
+ export function toMeasuredRevisionLayoutPosture(
255
+ posture: RevisionLayoutPosture | undefined,
256
+ ): RevisionLayoutPosture | undefined {
257
+ if (!posture?.affectsMeasuredText || posture.hiddenRevisionIds.length === 0) {
258
+ return undefined;
259
+ }
260
+ return {
261
+ ...posture,
262
+ visibleRevisionIds: [],
263
+ };
264
+ }
265
+
266
+ export function buildEffectiveLayoutFormatting(input: {
267
+ readonly paragraph?: EffectiveParagraphFormatting;
268
+ readonly runs?: readonly EffectiveRunFormatting[];
269
+ readonly table?: EffectiveTableFormatting;
270
+ readonly cell?: {
271
+ readonly paragraph: CanonicalParagraphFormatting;
272
+ readonly run: CanonicalRunFormatting;
273
+ };
274
+ readonly numbering?: NumberingLayoutInput;
275
+ readonly tabs?: readonly LayoutTabStopInput[];
276
+ readonly compatFlags?: readonly string[];
277
+ readonly revisionPosture?: RevisionLayoutPosture;
278
+ }): EffectiveLayoutFormatting {
279
+ const revisionPosture = toMeasuredRevisionLayoutPosture(input.revisionPosture);
280
+ const withoutHash = {
281
+ ...(input.paragraph ? { paragraph: input.paragraph } : {}),
282
+ runs: input.runs ?? [],
283
+ ...(input.table ? { table: input.table } : {}),
284
+ ...(input.cell ? { cell: input.cell } : {}),
285
+ ...(input.numbering ? { numbering: input.numbering } : {}),
286
+ tabs: input.tabs ?? [],
287
+ compatFlags: input.compatFlags ?? [],
288
+ ...(revisionPosture ? { revisionPosture } : {}),
289
+ };
290
+ return {
291
+ ...withoutHash,
292
+ structuralHash: createStructuralHash(withoutHash),
293
+ };
294
+ }
295
+
296
+ export function createStructuralHash(value: unknown): string {
297
+ const stable = stableStringify(value);
298
+ let hash = 0x811c9dc5;
299
+ for (let i = 0; i < stable.length; i += 1) {
300
+ hash ^= stable.charCodeAt(i);
301
+ hash = Math.imul(hash, 0x01000193);
302
+ }
303
+ return `fnv1a32:${(hash >>> 0).toString(16).padStart(8, "0")}`;
304
+ }
305
+
306
+ function stableStringify(value: unknown): string {
307
+ if (value === null || typeof value !== "object") {
308
+ return JSON.stringify(value);
309
+ }
310
+ if (Array.isArray(value)) {
311
+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
312
+ }
313
+ const record = value as Record<string, unknown>;
314
+ const keys = Object.keys(record)
315
+ .filter((key) => record[key] !== undefined)
316
+ .sort();
317
+ return `{${keys
318
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
319
+ .join(",")}}`;
320
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  ParagraphNode,
9
9
  ParagraphSpacing,
10
10
  TabStop,
11
+ Mutable,
11
12
  } from "../../../model/canonical-document.ts";
12
13
 
13
14
  export const DEFAULT_NUMBERING_START_AT = 1;
@@ -52,7 +53,7 @@ export interface ResolvedNumberingDefinitionSet {
52
53
  }
53
54
 
54
55
  export function resolveNumberingDefinitionSet(
55
- catalog: NumberingCatalog,
56
+ catalog: Mutable<NumberingCatalog>,
56
57
  numbering: ParagraphNode["numbering"] | undefined,
57
58
  paragraph?: Pick<ParagraphNode, "spacing" | "indentation" | "tabStops">,
58
59
  ): ResolvedNumberingDefinitionSet | null {
@@ -145,7 +146,7 @@ function mergeLevelDefinition(
145
146
  };
146
147
  }
147
148
 
148
- function withDefaultStartAt(level: NumberingLevelDefinition): NumberingLevelDefinition {
149
+ function withDefaultStartAt(level: Mutable<NumberingLevelDefinition>): NumberingLevelDefinition {
149
150
  return {
150
151
  ...level,
151
152
  startAt: level.startAt ?? DEFAULT_NUMBERING_START_AT,
@@ -156,7 +157,7 @@ function withDefaultStartAt(level: NumberingLevelDefinition): NumberingLevelDefi
156
157
  }
157
158
 
158
159
  function cloneLevelParagraphGeometry(
159
- geometry: NumberingLevelParagraphGeometry,
160
+ geometry: Mutable<NumberingLevelParagraphGeometry>,
160
161
  ): NumberingLevelParagraphGeometry {
161
162
  return {
162
163
  ...(geometry.justification ? { justification: geometry.justification } : {}),
@@ -244,7 +245,7 @@ function mergeParagraphSpacing(
244
245
  return undefined;
245
246
  }
246
247
 
247
- const spacing: ParagraphSpacing = {
248
+ const spacing: Mutable<ParagraphSpacing> = {
248
249
  ...(base ?? {}),
249
250
  ...(override ?? {}),
250
251
  };
@@ -260,24 +261,24 @@ function mergeParagraphIndentation(
260
261
  return undefined;
261
262
  }
262
263
 
263
- const out: ParagraphIndentation = {};
264
+ const out: Mutable<ParagraphIndentation> = {};
264
265
 
265
266
  const left = override?.left ?? base?.left;
266
- if (left !== undefined) out.left = left;
267
+ if (left !== undefined) (out as Mutable<typeof out>).left = left;
267
268
 
268
269
  const right = override?.right ?? base?.right;
269
- if (right !== undefined) out.right = right;
270
+ if (right !== undefined) (out as Mutable<typeof out>).right = right;
270
271
 
271
272
  // ECMA-376 §17.3.1.12: firstLine and hanging are mutually exclusive on a single
272
273
  // w:ind element. When override specifies either, it wins exclusively.
273
274
  const overrideTouchesFLorH =
274
275
  override?.firstLine !== undefined || override?.hanging !== undefined;
275
276
  if (overrideTouchesFLorH) {
276
- if (override?.hanging !== undefined) out.hanging = override.hanging;
277
- else if (override?.firstLine !== undefined) out.firstLine = override.firstLine;
277
+ if (override?.hanging !== undefined) (out as Mutable<typeof out>).hanging = override.hanging;
278
+ else if (override?.firstLine !== undefined) (out as Mutable<typeof out>).firstLine = override.firstLine;
278
279
  } else {
279
- if (base?.hanging !== undefined) out.hanging = base.hanging;
280
- else if (base?.firstLine !== undefined) out.firstLine = base.firstLine;
280
+ if (base?.hanging !== undefined) (out as Mutable<typeof out>).hanging = base.hanging;
281
+ else if (base?.firstLine !== undefined) (out as Mutable<typeof out>).firstLine = base.firstLine;
281
282
  }
282
283
 
283
284
  return Object.keys(out).length > 0 ? out : undefined;
@@ -333,7 +334,7 @@ function deriveTextColumn(
333
334
  };
334
335
  }
335
336
 
336
- function resolveHangingWidth(indentation: ParagraphIndentation): number | undefined {
337
+ function resolveHangingWidth(indentation: Mutable<ParagraphIndentation>): number | undefined {
337
338
  if (indentation.hanging !== undefined) {
338
339
  return indentation.hanging;
339
340
  }
@@ -28,6 +28,7 @@ import type {
28
28
  StylesCatalog,
29
29
  TableStyleConditionalRegion,
30
30
  TableStyleDefinition,
31
+ Mutable,
31
32
  } from "../../model/canonical-document.ts";
32
33
  import {
33
34
  resolveCharacterStyleChain,
@@ -51,7 +52,7 @@ export function mergeParagraph(
51
52
  over: CanonicalParagraphFormatting | undefined,
52
53
  ): CanonicalParagraphFormatting | undefined {
53
54
  if (!base && !over) return undefined;
54
- const merged: CanonicalParagraphFormatting = { ...(base ?? {}), ...(over ?? {}) };
55
+ const merged: Mutable<CanonicalParagraphFormatting> = { ...(base ?? {}), ...(over ?? {}) };
55
56
  if (base?.spacing || over?.spacing) {
56
57
  merged.spacing = { ...(base?.spacing ?? {}), ...(over?.spacing ?? {}) };
57
58
  }
@@ -10,6 +10,7 @@ import type {
10
10
  TableStyleDefinition,
11
11
  TableStyleFormatting,
12
12
  TableWidth,
13
+ Mutable,
13
14
  } from "../../model/canonical-document.ts";
14
15
 
15
16
  export interface ResolvedTableCellStyle {
@@ -58,7 +59,7 @@ export interface ResolvedTableStyleResolution {
58
59
  }>;
59
60
  }
60
61
 
61
- const DEFAULT_EFFECTIVE_TABLE_LOOK: TableLook = {
62
+ const DEFAULT_EFFECTIVE_TABLE_LOOK: Mutable<TableLook> = {
62
63
  val: "04A0",
63
64
  firstRow: true,
64
65
  lastRow: false,
@@ -80,7 +81,7 @@ const CONDITIONAL_REGION_ORDER: TableStyleConditionalRegion[] = [
80
81
  ];
81
82
 
82
83
  export function resolveTableStyleResolution(
83
- table: TableNode,
84
+ table: Mutable<TableNode>,
84
85
  tableStyles: Record<string, TableStyleDefinition>,
85
86
  ): ResolvedTableStyleResolution {
86
87
  const resolvedStyle = table.styleId ? resolveTableStyleDefinition(table.styleId, tableStyles) : {};
@@ -266,7 +267,7 @@ function decodeTableLookMask(val: string | undefined): TableLook | undefined {
266
267
  function getActiveRowRegions(
267
268
  rowIndex: number,
268
269
  rowCount: number,
269
- effectiveTblLook: TableLook,
270
+ effectiveTblLook: Mutable<TableLook>,
270
271
  ): TableStyleConditionalRegion[] {
271
272
  const active = new Set<TableStyleConditionalRegion>();
272
273
 
@@ -294,7 +295,7 @@ function getActiveCellRegions(
294
295
  startColumn: number,
295
296
  endColumn: number,
296
297
  columnCount: number,
297
- effectiveTblLook: TableLook,
298
+ effectiveTblLook: Mutable<TableLook>,
298
299
  ): TableStyleConditionalRegion[] {
299
300
  const active = new Set<TableStyleConditionalRegion>(rowRegions);
300
301
 
@@ -343,7 +344,7 @@ function mergeWholeStyleFormatting(
343
344
  return undefined;
344
345
  }
345
346
 
346
- const merged: TableStyleFormatting = {};
347
+ const merged: Mutable<TableStyleFormatting> = {};
347
348
  const table = mergeTableFormatting(base?.table, override?.table);
348
349
  const row = mergeRowFormatting(base?.row, override?.row);
349
350
  const cell = mergeCellFormatting(base?.cell, override?.cell);
@@ -429,14 +430,14 @@ function mergeBorderMap<T extends TableBorders | TableCellBorders>(
429
430
  }
430
431
 
431
432
  const sides = ["top", "left", "bottom", "right", "insideH", "insideV"] as const;
432
- const merged = {} as T;
433
+ const merged = {} as Mutable<T>;
433
434
  for (const side of sides) {
434
435
  const spec = mergePlainObject(base?.[side], override?.[side]) as BorderSpec | undefined;
435
436
  if (spec) {
436
437
  merged[side] = spec as T[typeof side];
437
438
  }
438
439
  }
439
- return Object.keys(merged).length > 0 ? merged : undefined;
440
+ return Object.keys(merged).length > 0 ? (merged as T) : undefined;
440
441
  }
441
442
 
442
443
  function mergePlainObject<T extends object>(base: T | undefined, override: T | undefined): T | undefined {
@@ -37,6 +37,7 @@ import type { EditorStoryTarget } from "../../api/public-types";
37
37
  import type {
38
38
  RenderFrame,
39
39
  RenderFrameRect,
40
+ RenderStoryRegion,
40
41
  } from "../render/index.ts";
41
42
  import type {
42
43
  CaretGeometry,
@@ -86,12 +87,14 @@ export function resolveCaretGeometry(
86
87
  * Resolve `GeometryRect[]` for a selection range. Slice 4 produces:
87
88
  * - an empty list when either endpoint can't be resolved
88
89
  * - a single zero-width caret rect when `from === to`
90
+ * - one line rect per covered render line when line ranges are available
89
91
  * - a single union rect otherwise (via `frame.anchorIndex.bySelection`)
90
92
  *
91
- * When the kernel's per-line anchor projection matures (Slice 5+), this
92
- * function flips to emitting one rect per line. The single-rect fallback
93
- * keeps chrome surfaces that currently call `getAnchorRects({ kind:
94
- * "runtime-offset", … })[0]` in sync with the new selection API.
93
+ * The line-aware path still tags rects `within-tolerance`: without per-run
94
+ * anchors we can identify covered lines but not the first/last glyph bounds.
95
+ * The single-rect fallback keeps chrome surfaces that currently call
96
+ * `getAnchorRects({ kind: "runtime-offset", … })[0]` in sync with the
97
+ * selection API when a frame lacks line boxes.
95
98
  */
96
99
  export function resolveSelectionRects(
97
100
  frame: RenderFrame | null,
@@ -119,14 +122,11 @@ export function resolveSelectionRects(
119
122
  return [caret.rect];
120
123
  }
121
124
 
125
+ const lineRects = resolveLineSelectionRects(frame, lo, hi, range.story);
126
+ if (lineRects.length > 0) return lineRects;
127
+
122
128
  const union = frame.anchorIndex.bySelection(lo, hi, range.story);
123
129
  if (!union) return [];
124
- // Slice 7b (2026-04-22): the substrate returns ONE union rect for any
125
- // non-collapsed range. That rect is geometrically correct at the
126
- // block level but loses per-line detail — tag it `within-tolerance`
127
- // so callers can gate per-line chrome work. When the render kernel
128
- // ships per-run anchors (refactor/05 Slice 7 Task 2), this site
129
- // upgrades to multiple `"exact"` rects automatically.
130
130
  return [toGeometryRect(union, "within-tolerance")];
131
131
  }
132
132
 
@@ -151,6 +151,78 @@ function toGeometryRect(
151
151
  return out;
152
152
  }
153
153
 
154
+ function resolveLineSelectionRects(
155
+ frame: RenderFrame,
156
+ from: number,
157
+ to: number,
158
+ story?: EditorStoryTarget,
159
+ ): readonly GeometryRect[] {
160
+ if (!Array.isArray(frame.pages)) return [];
161
+ const rects: GeometryRect[] = [];
162
+ for (const page of frame.pages) {
163
+ for (const region of collectRegions(page.regions)) {
164
+ if (!storyMatches(region.storyTarget, story)) continue;
165
+ for (const block of region.blocks) {
166
+ const blockFrom = block.fragment.from;
167
+ const blockTo = block.fragment.to;
168
+ if (blockTo <= from || blockFrom >= to) continue;
169
+ if (block.lines.length === 0) continue;
170
+ const lineCount = block.lines.length;
171
+ const span = Math.max(1, blockTo - blockFrom);
172
+ for (let i = 0; i < lineCount; i += 1) {
173
+ const line = block.lines[i]!;
174
+ const lineFrom =
175
+ blockFrom + Math.floor((span * i) / Math.max(1, lineCount));
176
+ const lineTo =
177
+ i === lineCount - 1
178
+ ? blockTo
179
+ : blockFrom +
180
+ Math.floor((span * (i + 1)) / Math.max(1, lineCount));
181
+ if (lineTo <= from || lineFrom >= to) continue;
182
+ rects.push(toGeometryRect(line.frame, "within-tolerance"));
183
+ }
184
+ }
185
+ }
186
+ }
187
+ return rects;
188
+ }
189
+
190
+ function collectRegions(
191
+ regions: RenderFrame["pages"][number]["regions"],
192
+ ): readonly RenderStoryRegion[] {
193
+ return [
194
+ regions.body,
195
+ regions.header,
196
+ regions.footer,
197
+ ...(regions.columns ?? []),
198
+ regions.footnoteArea,
199
+ ...(regions.footnotes ?? []),
200
+ ].filter((region): region is RenderStoryRegion => Boolean(region));
201
+ }
202
+
203
+ function storyMatches(
204
+ actual: EditorStoryTarget,
205
+ expected: EditorStoryTarget | undefined,
206
+ ): boolean {
207
+ if (!expected) return true;
208
+ if (actual.kind !== expected.kind) return false;
209
+ switch (expected.kind) {
210
+ case "main":
211
+ return true;
212
+ case "header":
213
+ case "footer":
214
+ return (
215
+ "relationshipId" in actual &&
216
+ actual.relationshipId === expected.relationshipId &&
217
+ actual.variant === expected.variant &&
218
+ actual.sectionIndex === expected.sectionIndex
219
+ );
220
+ case "footnote":
221
+ case "endnote":
222
+ return "noteId" in actual && actual.noteId === expected.noteId;
223
+ }
224
+ }
225
+
154
226
  /**
155
227
  * Approximate the baseline offset within a line rect. The render frame
156
228
  * does not yet carry per-anchor baseline metadata, so Slice 4 uses a
@@ -37,6 +37,8 @@ import type {
37
37
  BlockGeometry,
38
38
  CaretGeometry,
39
39
  EnvelopeBundle,
40
+ GeometryIndex,
41
+ GeometryIndexCoverage,
40
42
  GeometryRect,
41
43
  HitTestPoint,
42
44
  HitTestResult,
@@ -51,6 +53,11 @@ import {
51
53
  import { resolveHitTest } from "./hit-test.ts";
52
54
  import { resolveObjectHandles } from "./object-handles.ts";
53
55
  import { resolveAnchorRects } from "./project-anchors.ts";
56
+ import {
57
+ createUnavailableGeometryCoverage,
58
+ projectGeometryIndexFromFrame,
59
+ summarizeGeometryCoverageFromFrame,
60
+ } from "./geometry-index.ts";
54
61
  import {
55
62
  resolveReplacementEnvelope,
56
63
  type ReplacementScope,
@@ -67,6 +74,21 @@ import { createViewport, type ViewportHandle } from "./viewport.ts";
67
74
  // ---------------------------------------------------------------------------
68
75
 
69
76
  export interface GeometryFacet {
77
+ // PE2 index / coverage -------------------------------------------------
78
+ /**
79
+ * Renderer-neutral geometry index for the current frame. This is the PE2
80
+ * Slice-1 substrate: pages, regions, slices, lines, anchors, hit targets,
81
+ * and coverage metadata. Returns null when no render kernel/frame is warm.
82
+ */
83
+ getGeometryIndex(): GeometryIndex | null;
84
+
85
+ /**
86
+ * Lightweight coverage summary for evidence/debug consumers. Unlike
87
+ * `getGeometryIndex`, this always returns a value; unwired/pre-paint states
88
+ * report `status: "unavailable"` with zero counts.
89
+ */
90
+ getGeometryCoverage(): GeometryIndexCoverage;
91
+
70
92
  // Hit-test -------------------------------------------------------------
71
93
  /**
72
94
  * Resolve a point in the shell's overlay coordinate space to a canonical
@@ -226,6 +248,20 @@ export function createGeometryFacet(
226
248
  const viewport = input.viewport ?? createViewport();
227
249
 
228
250
  return {
251
+ getGeometryIndex() {
252
+ const kernel = input.renderKernel?.();
253
+ if (!kernel) return null;
254
+ return projectGeometryIndexFromFrame(kernel.getRenderFrame(), {
255
+ canonicalDocument: input.getCanonicalDocument?.() ?? null,
256
+ });
257
+ },
258
+
259
+ getGeometryCoverage() {
260
+ const kernel = input.renderKernel?.();
261
+ if (!kernel) return createUnavailableGeometryCoverage();
262
+ return summarizeGeometryCoverageFromFrame(kernel.getRenderFrame());
263
+ },
264
+
229
265
  hitTest(point) {
230
266
  // Slice 6 wrapper-deletion (2026-04-22): the layout-facet fallback
231
267
  // was deleted. `hitTest` now resolves only through the render-kernel