@beyondwork/docx-react-component 1.0.72 → 1.0.74

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +70 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/ai/policy.ts +31 -0
  6. package/src/api/v3/ui/_types.ts +21 -0
  7. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  8. package/src/api/v3/ui/overlays.ts +276 -2
  9. package/src/api/v3/ui/scope.ts +113 -1
  10. package/src/api/v3/ui/viewport.ts +1 -1
  11. package/src/compare/diff-engine.ts +1 -2
  12. package/src/core/commands/index.ts +14 -15
  13. package/src/core/selection/anchor-conversion.ts +2 -2
  14. package/src/core/selection/mapping.ts +10 -8
  15. package/src/core/selection/review-anchors.ts +3 -3
  16. package/src/core/state/editor-state.ts +49 -6
  17. package/src/io/export/export-session.ts +53 -0
  18. package/src/io/export/serialize-comments.ts +4 -4
  19. package/src/io/export/serialize-footnotes.ts +6 -0
  20. package/src/io/export/serialize-headers-footers.ts +6 -0
  21. package/src/io/export/serialize-main-document.ts +7 -0
  22. package/src/io/export/serialize-paragraph-formatting.ts +1 -1
  23. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  24. package/src/io/export/split-review-boundaries.ts +4 -4
  25. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  26. package/src/io/normalize/normalize-text.ts +38 -2
  27. package/src/io/ooxml/parse-comments.ts +2 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +127 -2
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
  31. package/src/model/anchor.ts +9 -1
  32. package/src/model/canonical-document.ts +76 -3
  33. package/src/preservation/store.ts +24 -0
  34. package/src/review/store/comment-anchors.ts +1 -1
  35. package/src/review/store/comment-remapping.ts +1 -1
  36. package/src/review/store/revision-actions.ts +4 -4
  37. package/src/review/store/revision-types.ts +1 -1
  38. package/src/review/store/scope-tag-diff.ts +1 -1
  39. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  40. package/src/runtime/document-runtime.ts +205 -37
  41. package/src/runtime/formatting/formatting-context.ts +1 -1
  42. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  43. package/src/runtime/layout/layout-engine-version.ts +30 -1
  44. package/src/runtime/layout/paginated-layout-engine.ts +47 -0
  45. package/src/runtime/layout/public-facet.ts +27 -0
  46. package/src/runtime/scopes/action-validation.ts +30 -4
  47. package/src/runtime/scopes/evidence.ts +1 -1
  48. package/src/runtime/scopes/replacement/apply.ts +1 -0
  49. package/src/runtime/scopes/review-bundle.ts +1 -1
  50. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  51. package/src/runtime/scopes/scope-range.ts +1 -1
  52. package/src/runtime/scopes/semantic-scope-types.ts +19 -0
  53. package/src/runtime/selection/post-edit-validator.ts +4 -4
  54. package/src/runtime/surface-projection.ts +94 -4
  55. package/src/session/import/loader-types.ts +18 -0
  56. package/src/session/import/loader.ts +2 -0
  57. package/src/session/import/review-import.ts +12 -12
  58. package/src/session/import/workflow-scope-import.ts +9 -8
  59. package/src/shell/session-bootstrap.ts +4 -0
  60. package/src/ui-tailwind/editor-surface/pm-schema.ts +32 -0
  61. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  62. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  63. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
  64. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  65. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
  66. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
  67. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
  68. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
  69. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  70. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  71. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
  72. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  73. package/src/ui-tailwind/tw-review-workspace.tsx +34 -49
  74. package/src/validation/compatibility-engine.ts +1 -1
  75. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  76. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
@@ -12,10 +12,13 @@
12
12
  * compiler-backed.
13
13
  */
14
14
 
15
- import type { CanonicalDocument } from "../../../model/canonical-document.ts";
15
+ import type {
16
+ CanonicalDocument,
17
+ InlineNode,
18
+ } from "../../../model/canonical-document.ts";
16
19
  import { resolveEffectiveFormattingForScope } from "../_formatting-seam.ts";
17
20
  import type { ParagraphLikeEnumeratedScope } from "../enumerate-scopes.ts";
18
- import { computeBlockPositions } from "../position-map.ts";
21
+ import { buildScopePositionMap, computeBlockPositions } from "../position-map.ts";
19
22
  import { deriveReplaceability } from "../replaceability.ts";
20
23
  import {
21
24
  isStructuredReplacementContent,
@@ -32,6 +35,99 @@ import {
32
35
  extractParagraphText,
33
36
  } from "./_paragraph-text.ts";
34
37
 
38
+ function inlineLengthLocal(node: InlineNode): number {
39
+ switch (node.type) {
40
+ case "text":
41
+ return Array.from(node.text).length;
42
+ case "hyperlink":
43
+ case "field":
44
+ return (node.children as readonly InlineNode[]).reduce(
45
+ (total, child) => total + inlineLengthLocal(child),
46
+ 0,
47
+ );
48
+ case "bookmark_start":
49
+ case "bookmark_end":
50
+ case "scope_marker_start":
51
+ case "scope_marker_end":
52
+ return 0;
53
+ default:
54
+ return 1;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Opt-in preservation helper: walk a paragraph's inline children,
60
+ * return the longest contiguous text-only run's [from, to] sub-range
61
+ * within the caller's `target` range. Opaque inlines / images / other
62
+ * non-text inlines break the run. Returns `null` when no text-only run
63
+ * exists inside the target range (paragraph is entirely opaques or the
64
+ * range falls on a non-text inline).
65
+ *
66
+ * Used by coord-08 §17 — `ReplacementPreservePolicy.opaqueFragments =
67
+ * true` narrows the replace range to this text-only sub-range so the
68
+ * opaque inlines survive the apply.
69
+ */
70
+ function longestTextOnlyRangeInParagraph(
71
+ paragraph: ParagraphLikeEnumeratedScope["paragraph"],
72
+ paragraphFrom: number,
73
+ target: { readonly from: number; readonly to: number },
74
+ ): { readonly from: number; readonly to: number } | null {
75
+ let best: { readonly from: number; readonly to: number } | null = null;
76
+ let runStart: number | null = null;
77
+ let cursor = paragraphFrom;
78
+
79
+ const consider = (from: number, to: number) => {
80
+ // Clip to the caller's target range.
81
+ const clippedFrom = Math.max(from, target.from);
82
+ const clippedTo = Math.min(to, target.to);
83
+ if (clippedTo <= clippedFrom) return;
84
+ if (!best || clippedTo - clippedFrom > best.to - best.from) {
85
+ best = { from: clippedFrom, to: clippedTo };
86
+ }
87
+ };
88
+
89
+ const isTextRun = (child: InlineNode): boolean => {
90
+ if (child.type === "text" || child.type === "tab" || child.type === "hard_break") {
91
+ // tab + hard_break carry length 1 and are safe to text-replace
92
+ // (the runtime's text.insert handles them structurally). Treat
93
+ // as part of the text run.
94
+ return true;
95
+ }
96
+ if (child.type === "hyperlink" || child.type === "field") {
97
+ // Hyperlink / field wrappers contribute their text-only children
98
+ // to the run; they're not themselves opaque.
99
+ return true;
100
+ }
101
+ if (
102
+ child.type === "bookmark_start" ||
103
+ child.type === "bookmark_end" ||
104
+ child.type === "scope_marker_start" ||
105
+ child.type === "scope_marker_end"
106
+ ) {
107
+ // Zero-width boundary markers — preserve via length 0. Don't
108
+ // break the text run.
109
+ return true;
110
+ }
111
+ return false;
112
+ };
113
+
114
+ for (const child of paragraph.children) {
115
+ const len = inlineLengthLocal(child);
116
+ const childFrom = cursor;
117
+ const childTo = cursor + len;
118
+ if (isTextRun(child)) {
119
+ if (runStart === null) runStart = childFrom;
120
+ } else {
121
+ // Opaque / image / non-text inline breaks the run.
122
+ if (runStart !== null) consider(runStart, childFrom);
123
+ runStart = null;
124
+ }
125
+ cursor = childTo;
126
+ }
127
+ if (runStart !== null) consider(runStart, cursor);
128
+ return best;
129
+ }
130
+
35
131
  export interface CompileParagraphOptions {
36
132
  readonly document?: CanonicalDocument;
37
133
  readonly paragraphIndex?: number;
@@ -125,10 +221,71 @@ export function compileParagraphReplacement(
125
221
  const blockRange = blocks.find((b) => b.blockIndex === entry.blockIndex);
126
222
  if (!blockRange) return null;
127
223
 
224
+ // Inline-range correction (2026-04-24 bug fix): when the scope is
225
+ // marker-backed AND the marker pair sits strictly INSIDE the
226
+ // paragraph (marker range is narrower than the paragraph's full
227
+ // block range), the replace range must follow the markers — not
228
+ // the paragraph. Otherwise surrounding text in the same paragraph
229
+ // gets destroyed on every applyReplacementScope call. The markers'
230
+ // positions are already tracked in `buildScopePositionMap().
231
+ // markerScopes`; we consult it here only when the scope is marker-
232
+ // backed so non-marker paragraphs pay zero cost.
233
+ let effectiveRange: { readonly from: number; readonly to: number } = {
234
+ from: blockRange.from,
235
+ to: blockRange.to,
236
+ };
237
+ let rangeKind: "block" | "inline-marker" | "opaque-preserving-text" = "block";
238
+ if (entry.handle.provenance === "marker-backed") {
239
+ const positionMap = buildScopePositionMap(options.document);
240
+ const markerRange = positionMap.markerScopes.get(entry.handle.scopeId);
241
+ if (
242
+ markerRange &&
243
+ markerRange.from >= blockRange.from &&
244
+ markerRange.to <= blockRange.to &&
245
+ // Strictly narrower than the paragraph — only then is the marker
246
+ // a sub-paragraph inline range. Whole-paragraph marker scopes
247
+ // (markers wrapping all of the paragraph's text) continue to use
248
+ // the block range, preserving the existing replace-whole-paragraph
249
+ // semantics.
250
+ (markerRange.from > blockRange.from || markerRange.to < blockRange.to)
251
+ ) {
252
+ effectiveRange = markerRange;
253
+ rangeKind = "inline-marker";
254
+ }
255
+ }
256
+
257
+ // Coord-08 §17 (2026-04-24) — opt-in opaque preservation. When the
258
+ // caller sets `preserve.opaqueFragments === true`, narrow the
259
+ // replace range to the longest contiguous text-only sub-range so
260
+ // opaque inlines (images, charts, preserve-only fragments) survive
261
+ // the apply at their original positions.
262
+ if (proposed.preserve?.opaqueFragments === true) {
263
+ const textOnly = longestTextOnlyRangeInParagraph(
264
+ entry.paragraph,
265
+ blockRange.from,
266
+ effectiveRange,
267
+ );
268
+ if (!textOnly) {
269
+ // Paragraph is entirely opaques within the target range — no
270
+ // text-only sub-range to replace. Refuse cleanly; the composer's
271
+ // `preserve:opaque-fragment:*` warning already signalled the
272
+ // opt-in intent.
273
+ return null;
274
+ }
275
+ effectiveRange = textOnly;
276
+ rangeKind = "opaque-preserving-text";
277
+ }
278
+
128
279
  if (proposed.proposedContent.kind === "text") {
129
280
  const text = proposed.proposedContent.text ?? "";
130
281
  const stepKind =
131
282
  options.posture === "suggest-mode" ? "text-insert-tracked" : "text-replace";
283
+ const summaryScope =
284
+ rangeKind === "inline-marker"
285
+ ? `paragraph #${entry.blockIndex} inline-marker range [${effectiveRange.from}..${effectiveRange.to}]`
286
+ : rangeKind === "opaque-preserving-text"
287
+ ? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
288
+ : `paragraph #${entry.blockIndex}`;
132
289
  return {
133
290
  scopeId: entry.handle.scopeId,
134
291
  targetKind: "paragraph",
@@ -138,9 +295,9 @@ export function compileParagraphReplacement(
138
295
  kind: stepKind,
139
296
  summary:
140
297
  stepKind === "text-replace"
141
- ? `replace paragraph #${entry.blockIndex} text (len ${text.length})`
142
- : `suggest-mode paragraph #${entry.blockIndex} text replace (len ${text.length})`,
143
- range: { from: blockRange.from, to: blockRange.to },
298
+ ? `replace ${summaryScope} text (len ${text.length})`
299
+ : `suggest-mode ${summaryScope} text replace (len ${text.length})`,
300
+ range: { from: effectiveRange.from, to: effectiveRange.to },
144
301
  text,
145
302
  },
146
303
  ]),
@@ -161,6 +318,12 @@ export function compileParagraphReplacement(
161
318
  const fragment = proposed.proposedContent.structured;
162
319
  if (!isStructuredReplacementContent(fragment)) return null;
163
320
  const blockCount = fragment.blocks.length;
321
+ const summaryScope =
322
+ rangeKind === "inline-marker"
323
+ ? `paragraph #${entry.blockIndex} inline-marker range [${effectiveRange.from}..${effectiveRange.to}]`
324
+ : rangeKind === "opaque-preserving-text"
325
+ ? `paragraph #${entry.blockIndex} opaque-preserving text range [${effectiveRange.from}..${effectiveRange.to}]`
326
+ : `paragraph #${entry.blockIndex}`;
164
327
  return {
165
328
  scopeId: entry.handle.scopeId,
166
329
  targetKind: "paragraph",
@@ -168,8 +331,8 @@ export function compileParagraphReplacement(
168
331
  steps: Object.freeze([
169
332
  {
170
333
  kind: "fragment-replace",
171
- summary: `replace paragraph #${entry.blockIndex} with structured fragment (${blockCount} block(s))`,
172
- range: { from: blockRange.from, to: blockRange.to },
334
+ summary: `replace ${summaryScope} with structured fragment (${blockCount} block(s))`,
335
+ range: { from: effectiveRange.from, to: effectiveRange.to },
173
336
  fragment,
174
337
  },
175
338
  ]),
@@ -39,7 +39,7 @@ import type {
39
39
  function anchorToRange(anchor: CanonicalAnchor): ScopePositionRange | null {
40
40
  switch (anchor.kind) {
41
41
  case "range":
42
- return { from: anchor.range.from, to: anchor.range.to };
42
+ return { from: anchor.from, to: anchor.to };
43
43
  case "node":
44
44
  return { from: anchor.at, to: anchor.at };
45
45
  case "detached":
@@ -296,6 +296,25 @@ export interface ReplacementPreservePolicy {
296
296
  readonly comments?: boolean;
297
297
  readonly revisions?: boolean;
298
298
  readonly bookmarks?: boolean;
299
+ /**
300
+ * Opt-in (2026-04-24): allow the replace to proceed when the target
301
+ * range contains opaque fragments (inline images, charts, or other
302
+ * `preserve-only` payload) that the runtime cannot safely
303
+ * re-serialize. When `true`:
304
+ *
305
+ * - `computePreservationVerdict` downgrades
306
+ * `preserve:opaque-fragment:*` reasons from blockers to warnings
307
+ * (still surfaced on `validation.warnings` + audit-bundle).
308
+ * - `compileParagraphReplacement` narrows the replace range to the
309
+ * longest contiguous text-only sub-range, leaving opaques at
310
+ * their original inline offsets. If no text sub-range exists
311
+ * (paragraph is entirely opaques), compile still refuses.
312
+ *
313
+ * Default (absent / `false`) preserves the pre-opt-in refusal
314
+ * semantics. Callers that don't set this flag are protected from
315
+ * accidental opaque-fragment loss.
316
+ */
317
+ readonly opaqueFragments?: boolean;
299
318
  }
300
319
 
301
320
  export interface ReplacementScope {
@@ -64,8 +64,7 @@ export function validateSelectionAgainstDocument(
64
64
  head: collapsed,
65
65
  isCollapsed: true,
66
66
  activeRange: {
67
- kind: "range",
68
- range: { from: collapsed, to: collapsed },
67
+ kind: "range", from: collapsed, to: collapsed,
69
68
  assoc: {
70
69
  start: selection.activeRange.assoc,
71
70
  end: selection.activeRange.assoc,
@@ -84,7 +83,8 @@ export function validateSelectionAgainstDocument(
84
83
  return selection;
85
84
  }
86
85
 
87
- const range = { from: Math.min(anchor, head), to: Math.max(anchor, head) };
86
+ const from = Math.min(anchor, head);
87
+ const to = Math.max(anchor, head);
88
88
  const assoc =
89
89
  selection.activeRange.kind === "range"
90
90
  ? selection.activeRange.assoc
@@ -94,7 +94,7 @@ export function validateSelectionAgainstDocument(
94
94
  anchor,
95
95
  head,
96
96
  isCollapsed: anchor === head,
97
- activeRange: { kind: "range", range, assoc },
97
+ activeRange: { kind: "range", from, to, assoc },
98
98
  };
99
99
  }
100
100
 
@@ -95,6 +95,20 @@ interface ParagraphAccumulator {
95
95
  }
96
96
 
97
97
  export interface SurfaceProjectionOptions {
98
+ /**
99
+ * Block-index intervals to render as real (non-placeholder). Blocks whose
100
+ * index falls in ANY interval are built as real `SurfaceBlockSnapshot`s;
101
+ * all others become size-preserving `placeholder-culled` opaque blocks.
102
+ *
103
+ * The canonical shape is an array of non-overlapping intervals (typically
104
+ * one for the visible viewport + overscan, and — when the caret is scrolled
105
+ * off-screen — a second one covering the caret's page so the selection
106
+ * block stays real without realizing the document-scale gap between the
107
+ * two). Callers still supplying the legacy scalar `viewportBlockRange` get
108
+ * wrapped into a single-element array internally.
109
+ */
110
+ viewportBlockRanges?: readonly { start: number; end: number }[] | null;
111
+ /** @deprecated use `viewportBlockRanges`. Kept for back-compat; wrapped into a 1-element array when supplied alone. */
98
112
  viewportBlockRange?: { start: number; end: number } | null;
99
113
  /**
100
114
  * Active markup mode. When set together with the document's
@@ -146,7 +160,13 @@ export function createEditorSurfaceSnapshot(
146
160
  activeStory: EditorStoryTarget = { kind: "main" },
147
161
  options: SurfaceProjectionOptions = {},
148
162
  ): EditorSurfaceSnapshot {
149
- const viewportBlockRange = options.viewportBlockRange ?? null;
163
+ const viewportBlockRanges: readonly { start: number; end: number }[] | null = (() => {
164
+ if (options.viewportBlockRanges != null) {
165
+ return options.viewportBlockRanges.length === 0 ? null : options.viewportBlockRanges;
166
+ }
167
+ if (options.viewportBlockRange != null) return [options.viewportBlockRange];
168
+ return null;
169
+ })();
150
170
  const root = normalizeDocumentRoot({
151
171
  type: "doc",
152
172
  children: [...getStoryBlocks(document, activeStory)],
@@ -199,8 +219,8 @@ export function createEditorSurfaceSnapshot(
199
219
 
200
220
  for (let index = 0; index < root.children.length; index += 1) {
201
221
  const isInViewport =
202
- viewportBlockRange === null ||
203
- (index >= viewportBlockRange.start && index < viewportBlockRange.end);
222
+ viewportBlockRanges === null ||
223
+ isIndexInAnyRange(index, viewportBlockRanges);
204
224
  // L7 Phase 2.9 — viewport bail on the style-cascade work. When the
205
225
  // block is outside the viewport, the surface block produced below is
206
226
  // immediately discarded in favor of a `placeholder-culled` entry (we
@@ -293,10 +313,25 @@ export function createEditorSurfaceSnapshot(
293
313
  blocks,
294
314
  lockedFragmentIds,
295
315
  secondaryStories,
296
- viewportBlockRange,
316
+ viewportBlockRanges,
297
317
  };
298
318
  }
299
319
 
320
+ /**
321
+ * True when `index` falls in any of the given half-open intervals. Linear
322
+ * scan because the typical caller passes 1 or 2 ranges; switching to binary
323
+ * search has no measurable payoff below ~8 ranges.
324
+ */
325
+ function isIndexInAnyRange(
326
+ index: number,
327
+ ranges: readonly { start: number; end: number }[],
328
+ ): boolean {
329
+ for (const r of ranges) {
330
+ if (index >= r.start && index < r.end) return true;
331
+ }
332
+ return false;
333
+ }
334
+
300
335
  function createSurfaceBlock(
301
336
  block: BlockNode,
302
337
  document: CanonicalDocumentEnvelope,
@@ -796,6 +831,58 @@ function resolveSurfaceParagraphShading(
796
831
  };
797
832
  }
798
833
 
834
+ /**
835
+ * Strip the verbatim-OOXML `rawXml` round-trip field from
836
+ * `FrameProperties` before forwarding to the public surface. Consumers
837
+ * (L04 paginated-layout placement, L11 render) read the modeled
838
+ * positioning + size fields; `rawXml` exists only so the serializer can
839
+ * merge extension attributes (`w14:*`, `w15:*`, `mc:Ignorable`) back
840
+ * onto the element on export.
841
+ */
842
+ function toSurfaceFrameProperties(
843
+ frame: NonNullable<CanonicalParagraphFormatting["frameProperties"]>,
844
+ ): NonNullable<
845
+ Extract<
846
+ import("../api/public-types.ts").SurfaceBlockSnapshot,
847
+ { kind: "paragraph" }
848
+ >["frameProperties"]
849
+ > {
850
+ const {
851
+ widthTwips,
852
+ heightTwips,
853
+ hRule,
854
+ xTwips,
855
+ yTwips,
856
+ xAlign,
857
+ yAlign,
858
+ hAnchor,
859
+ vAnchor,
860
+ wrap,
861
+ hSpaceTwips,
862
+ vSpaceTwips,
863
+ dropCap,
864
+ lines,
865
+ anchorLock,
866
+ } = frame;
867
+ return {
868
+ ...(widthTwips !== undefined ? { widthTwips } : {}),
869
+ ...(heightTwips !== undefined ? { heightTwips } : {}),
870
+ ...(hRule !== undefined ? { hRule } : {}),
871
+ ...(xTwips !== undefined ? { xTwips } : {}),
872
+ ...(yTwips !== undefined ? { yTwips } : {}),
873
+ ...(xAlign !== undefined ? { xAlign } : {}),
874
+ ...(yAlign !== undefined ? { yAlign } : {}),
875
+ ...(hAnchor !== undefined ? { hAnchor } : {}),
876
+ ...(vAnchor !== undefined ? { vAnchor } : {}),
877
+ ...(wrap !== undefined ? { wrap } : {}),
878
+ ...(hSpaceTwips !== undefined ? { hSpaceTwips } : {}),
879
+ ...(vSpaceTwips !== undefined ? { vSpaceTwips } : {}),
880
+ ...(dropCap !== undefined ? { dropCap } : {}),
881
+ ...(lines !== undefined ? { lines } : {}),
882
+ ...(anchorLock !== undefined ? { anchorLock } : {}),
883
+ };
884
+ }
885
+
799
886
  function resolveSurfaceParagraphFormatting(
800
887
  formatting: CanonicalParagraphFormatting,
801
888
  themeResolver: ThemeColorResolver | undefined,
@@ -1066,6 +1153,9 @@ function createParagraphBlock(
1066
1153
  : {}),
1067
1154
  ...(paragraph.indentation ? { indentation: paragraph.indentation } : {}),
1068
1155
  ...(paragraph.borders ? { borders: paragraph.borders } : {}),
1156
+ ...(paragraph.frameProperties
1157
+ ? { frameProperties: toSurfaceFrameProperties(paragraph.frameProperties) }
1158
+ : {}),
1069
1159
  ...(paragraph.shading
1070
1160
  ? { shading: resolveSurfaceParagraphShading(paragraph.shading, themeResolver) }
1071
1161
  : {}),
@@ -119,6 +119,24 @@ export interface LoadDocxEditorSessionOptions {
119
119
  * FRESH offload on first-open.
120
120
  */
121
121
  offloadEmbeddedDocuments?: boolean;
122
+ /**
123
+ * Phase 1 cosmetic-marker strip. When `true` (the default), the
124
+ * OOXML parser drops `<w:lastRenderedPageBreak/>`, `<w:proofErr/>`,
125
+ * and `<w:noBreakHyphen/>` during the body walk instead of emitting
126
+ * them as `opaque_inline` nodes. Aggregate counts are surfaced as
127
+ * a single `diagnostic:parser.skipped-cosmetic-markers` warning on
128
+ * the returned canonical document.
129
+ *
130
+ * These markers carry no contract semantics (Word regenerates them
131
+ * on reopen) and are structural boundaries that today cause
132
+ * `replaceText` to refuse ranges that happen to cross them. See
133
+ * `docs/architecture/cosmetic-marker-strip.md`.
134
+ *
135
+ * Set to `false` to preserve pre-strip behavior exactly — useful for
136
+ * byte-preservation tests and diagnostic scripts that need to
137
+ * inspect every marker.
138
+ */
139
+ stripCosmeticMarkers?: boolean;
122
140
  }
123
141
 
124
142
  /**
@@ -540,6 +540,7 @@ export async function loadDocxSessionAsync(
540
540
  mediaParts,
541
541
  mainDocumentPath,
542
542
  chartPartLookup,
543
+ { stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
543
544
  );
544
545
  } finally {
545
546
  if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
@@ -1312,6 +1313,7 @@ export function loadDocxSessionSync(
1312
1313
  mediaParts,
1313
1314
  mainDocumentPath,
1314
1315
  chartPartLookup,
1316
+ { stripCosmeticMarkers: options.stripCosmeticMarkers !== false },
1315
1317
  );
1316
1318
  } finally {
1317
1319
  if (options.telemetryBus) setActiveParseTelemetryBus(undefined);
@@ -82,7 +82,7 @@ export function normalizeImportedRevisionRecords(
82
82
 
83
83
  const preserveOnlyReason =
84
84
  getStructuralPreserveOnlyReason(revision, paragraphRanges) ??
85
- (opaqueRanges.some((range) => rangesIntersect(range, anchor.range))
85
+ (opaqueRanges.some((range) => rangesIntersect(range, { from: anchor.from, to: anchor.to }))
86
86
  ? "Imported revision overlaps preserve-only OOXML and remains preserve-only."
87
87
  : undefined);
88
88
 
@@ -118,7 +118,7 @@ export function normalizeImportedCommentThreads(
118
118
  ) {
119
119
  return [];
120
120
  }
121
- return [revision.anchor.range];
121
+ return [{ from: revision.anchor.from, to: revision.anchor.to }];
122
122
  });
123
123
  const preserveOnlyCommentIds = new Set(parsed.diagnostics.map((diagnostic) => diagnostic.commentId));
124
124
  const additionalDiagnostics: CommentImportDiagnostic[] = [];
@@ -129,7 +129,7 @@ export function normalizeImportedCommentThreads(
129
129
  return thread;
130
130
  }
131
131
 
132
- const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, anchor.range));
132
+ const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, { from: anchor.from, to: anchor.to }));
133
133
  if (opaqueOverlap) {
134
134
  preserveOnlyCommentIds.add(thread.commentId);
135
135
  additionalDiagnostics.push({
@@ -143,7 +143,7 @@ export function normalizeImportedCommentThreads(
143
143
  });
144
144
  return {
145
145
  ...thread,
146
- anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
146
+ anchor: createDetachedAnchor({ from: anchor.from, to: anchor.to }, "importAmbiguity"),
147
147
  status: "detached",
148
148
  metadata: {
149
149
  ...thread.metadata,
@@ -155,7 +155,7 @@ export function normalizeImportedCommentThreads(
155
155
  }
156
156
 
157
157
  const preserveOnlyRevisionOverlap = preserveOnlyRevisionRanges.some((range) =>
158
- rangesIntersect(range, anchor.range),
158
+ rangesIntersect(range, { from: anchor.from, to: anchor.to }),
159
159
  );
160
160
  if (preserveOnlyRevisionOverlap) {
161
161
  preserveOnlyCommentIds.add(thread.commentId);
@@ -170,7 +170,7 @@ export function normalizeImportedCommentThreads(
170
170
  });
171
171
  return {
172
172
  ...thread,
173
- anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
173
+ anchor: createDetachedAnchor({ from: anchor.from, to: anchor.to }, "importAmbiguity"),
174
174
  status: "detached",
175
175
  metadata: {
176
176
  ...thread.metadata,
@@ -257,7 +257,7 @@ export function getStructuralPreserveOnlyReason(
257
257
 
258
258
  if (
259
259
  (form === "run-insertion" || form === "run-deletion") &&
260
- anchor.range.from === anchor.range.to
260
+ anchor.from === anchor.to
261
261
  ) {
262
262
  return "Imported zero-width run revision remains preserve-only.";
263
263
  }
@@ -265,9 +265,9 @@ export function getStructuralPreserveOnlyReason(
265
265
  if (form === "paragraph-insertion" || form === "paragraph-deletion") {
266
266
  const paragraphBoundary = paragraphRanges.find(
267
267
  (boundary) =>
268
- boundary.end === anchor.range.from ||
269
- (anchor.range.from >= boundary.start &&
270
- anchor.range.from <= boundary.end),
268
+ boundary.end === anchor.from ||
269
+ (anchor.from >= boundary.start &&
270
+ anchor.from <= boundary.end),
271
271
  );
272
272
  return paragraphBoundary
273
273
  ? undefined
@@ -276,8 +276,8 @@ export function getStructuralPreserveOnlyReason(
276
276
 
277
277
  const paragraphBoundary = paragraphRanges.find(
278
278
  (boundary) =>
279
- anchor.range.from >= boundary.start &&
280
- anchor.range.to <= boundary.end,
279
+ anchor.from >= boundary.start &&
280
+ anchor.to <= boundary.end,
281
281
  );
282
282
  return paragraphBoundary
283
283
  ? undefined
@@ -6,12 +6,13 @@
6
6
  *
7
7
  * P6-clean: `CommentThread` reaches through `src/model/review/`; every
8
8
  * workflow type comes from `src/api/public-types.ts` (an allowed surface
9
- * for the session layer). Anchor-shape conversion reaches through the
10
- * shared boundary helper at `src/api/anchor-conversion.ts` (relocated in
11
- * slice 5e-6g).
9
+ * for the session layer). Anchor-shape conversion is no longer needed —
10
+ * L02's FLAT WINS collapse (2026-04-24, `5b2f6f56`) made `CanonicalAnchor`,
11
+ * `InternalEditorAnchorProjection`, and the public `EditorAnchorProjection`
12
+ * structurally identical, so `thread.anchor` flows directly into the
13
+ * `WorkflowScope.anchor` slot without a helper call.
12
14
  */
13
15
 
14
- import { toPublicAnchorProjection } from "../../api/anchor-conversion.ts";
15
16
  import type {
16
17
  WorkflowMetadataSnapshot,
17
18
  WorkflowOverlay,
@@ -155,7 +156,7 @@ export function createClmWorkflowScope(
155
156
  scopeId,
156
157
  version,
157
158
  mode: directive.mode,
158
- anchor: toPublicAnchorProjection(thread.anchor),
159
+ anchor: thread.anchor,
159
160
  storyTarget: { kind: "main" },
160
161
  workItemId,
161
162
  label: directive.description,
@@ -166,7 +167,7 @@ export function createClmWorkflowScope(
166
167
  scopeId,
167
168
  version,
168
169
  mode: directive.mode,
169
- anchor: toPublicAnchorProjection(thread.anchor),
170
+ anchor: thread.anchor,
170
171
  storyTarget: { kind: "main" },
171
172
  label: directive.description,
172
173
  metadata: createClmScopeMetadata(directive),
@@ -240,8 +241,8 @@ export function getNextClmScopeVersion(
240
241
  anchor: Extract<CommentThread["anchor"], { kind: "range" }>,
241
242
  ): number {
242
243
  const anchorRange = {
243
- from: anchor.range.from,
244
- to: anchor.range.to,
244
+ from: anchor.from,
245
+ to: anchor.to,
245
246
  };
246
247
  const overlappingVersions = scopes.flatMap((scope) => {
247
248
  if (scope.anchor.kind !== "range") {
@@ -1139,6 +1139,9 @@ function createLoadingRuntimeBridge(input: {
1139
1139
  },
1140
1140
  getScope: () => null,
1141
1141
  compileScopeBundleById: () => null,
1142
+ compileScopeList: () => [],
1143
+ compileScopeCardById: () => null,
1144
+ compileScopeRailSnapshot: () => ({ segments: [] }),
1142
1145
  getMarkerBackedScopeIds: () => new Set<string>(),
1143
1146
  debug: createLoadingDebugFacet(),
1144
1147
  removeScope: () => undefined,
@@ -1281,6 +1284,7 @@ function createLoadingRuntimeBridge(input: {
1281
1284
  getPerfCountersSnapshot: () => ({}),
1282
1285
  resetPerfCounters: () => undefined,
1283
1286
  setVisibleBlockRange: () => undefined,
1287
+ setVisibleBlockRanges: () => undefined,
1284
1288
  requestViewportRefresh: () => undefined,
1285
1289
  addInvisibleScope: () => ({ scopeId: "", anchor: { kind: "range", from: 0, to: 0, assoc: { start: -1, end: 1 } } }),
1286
1290
  setScopeVisibility: () => undefined,
@@ -200,6 +200,15 @@ export const editorSchema = new Schema({
200
200
  hiddenTextOnly: { default: null },
201
201
  placeholderCulled: { default: null },
202
202
  blockId: { default: null },
203
+ /**
204
+ * `<w:framePr>` projection from `SurfaceBlockFragment.frameProperties`
205
+ * (ECMA-376 §17.3.1.11). When set (and `dropCap` is neither `"drop"`
206
+ * nor `"margin"`), `toDOM` emits `position: absolute` with the
207
+ * xTwips/yTwips/widthTwips/heightTwips fields; the inline flow
208
+ * already treats the paragraph as zero-height per L04
209
+ * `measureBlockHeight` short-circuit (a298391e / coord-04 §1.19.d).
210
+ */
211
+ frameProperties: { default: null },
203
212
  },
204
213
  parseDOM: [{ tag: "p" }],
205
214
  toDOM(node) {
@@ -278,6 +287,29 @@ export const editorSchema = new Schema({
278
287
  }
279
288
  const pageBreak = node.attrs.pageBreakBefore as boolean | null;
280
289
  if (pageBreak) styles.push("border-top: 2px dashed rgba(0,0,0,0.1); padding-top: 8px; margin-top: 16px");
290
+ // `<w:framePr>` out-of-flow frame — mirror the static-path branch in
291
+ // tw-page-block-view.helpers.ts so PM-rendered page 1 absolutely
292
+ // positions the frame identically to pages 2+.
293
+ const framePr = node.attrs.frameProperties as
294
+ | {
295
+ xTwips?: number;
296
+ yTwips?: number;
297
+ widthTwips?: number;
298
+ heightTwips?: number;
299
+ hRule?: "auto" | "atLeast" | "exact";
300
+ dropCap?: "none" | "drop" | "margin";
301
+ }
302
+ | null;
303
+ if (framePr && framePr.dropCap !== "drop" && framePr.dropCap !== "margin") {
304
+ styles.push("position: absolute");
305
+ if (typeof framePr.xTwips === "number") styles.push(`left: ${framePr.xTwips / 20}pt`);
306
+ if (typeof framePr.yTwips === "number") styles.push(`top: ${framePr.yTwips / 20}pt`);
307
+ if (typeof framePr.widthTwips === "number") styles.push(`width: ${framePr.widthTwips / 20}pt`);
308
+ if (typeof framePr.heightTwips === "number") {
309
+ if (framePr.hRule === "exact") styles.push(`height: ${framePr.heightTwips / 20}pt`);
310
+ else if (framePr.hRule === "atLeast") styles.push(`min-height: ${framePr.heightTwips / 20}pt`);
311
+ }
312
+ }
281
313
  const hiddenTextOnly = node.attrs.hiddenTextOnly as boolean | null;
282
314
  if (hiddenTextOnly) {
283
315
  attrs["data-hidden-paragraph"] = "true";