@beyondwork/docx-react-component 1.0.71 → 1.0.73

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 (87) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +280 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/policy.ts +31 -0
  9. package/src/api/v3/ai/replacement.ts +8 -0
  10. package/src/api/v3/ai/review.ts +342 -0
  11. package/src/api/v3/ai/stats.ts +62 -0
  12. package/src/api/v3/runtime/viewport.ts +181 -0
  13. package/src/api/v3/runtime/workflow.ts +114 -1
  14. package/src/api/v3/ui/_types.ts +35 -0
  15. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  16. package/src/api/v3/ui/index.ts +1 -0
  17. package/src/api/v3/ui/viewport.ts +112 -0
  18. package/src/compare/diff-engine.ts +2 -0
  19. package/src/core/commands/formatting-commands.ts +1 -0
  20. package/src/core/commands/table-structure-commands.ts +1 -0
  21. package/src/core/state/editor-state.ts +49 -6
  22. package/src/io/export/serialize-footnotes.ts +6 -0
  23. package/src/io/export/serialize-headers-footers.ts +7 -0
  24. package/src/io/export/serialize-main-document.ts +20 -0
  25. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  26. package/src/io/export/split-review-boundaries.ts +1 -0
  27. package/src/io/normalize/normalize-text.ts +49 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +148 -7
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  31. package/src/model/canonical-document.ts +401 -1
  32. package/src/runtime/formatting/formatting-context.ts +2 -1
  33. package/src/runtime/geometry/overlay-rects.ts +7 -10
  34. package/src/runtime/layout/layout-engine-version.ts +278 -1
  35. package/src/runtime/layout/paginated-layout-engine.ts +181 -8
  36. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  37. package/src/runtime/markdown-sanitizer.ts +21 -4
  38. package/src/runtime/render/render-kernel.ts +21 -1
  39. package/src/runtime/scopes/action-validation.ts +30 -4
  40. package/src/runtime/scopes/audit-bundle.ts +8 -0
  41. package/src/runtime/scopes/compiler-service.ts +1 -0
  42. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  43. package/src/runtime/scopes/replacement/apply.ts +50 -3
  44. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  45. package/src/runtime/scopes/semantic-scope-types.ts +27 -0
  46. package/src/runtime/surface-projection.ts +77 -0
  47. package/src/runtime/workflow/coordinator.ts +3 -0
  48. package/src/runtime/workflow/scope-writer.ts +34 -0
  49. package/src/session/export/embedded-reconstitute.ts +37 -3
  50. package/src/session/import/embedded-offload.ts +26 -1
  51. package/src/session/import/loader-types.ts +18 -0
  52. package/src/session/import/loader.ts +2 -0
  53. package/src/shell/media-previews.ts +8 -6
  54. package/src/ui/WordReviewEditor.tsx +1 -0
  55. package/src/ui/editor-surface-controller.tsx +11 -0
  56. package/src/ui/headless/selection-helpers.ts +2 -2
  57. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  58. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  59. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  60. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  62. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  63. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  64. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  65. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  66. package/src/ui-tailwind/editor-surface/pm-schema.ts +50 -4
  67. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  68. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  69. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  70. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +114 -0
  71. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  72. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  73. package/src/ui-tailwind/index.ts +4 -2
  74. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  75. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +54 -34
  76. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  77. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  78. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  79. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +8 -1
  80. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +11 -1
  81. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  82. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +139 -10
  83. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  84. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  85. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  86. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  87. package/src/ui-tailwind/tw-review-workspace.tsx +22 -14
@@ -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
  ]),
@@ -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 {
@@ -426,6 +445,14 @@ export interface ScopeActionAudit {
426
445
  }[];
427
446
  readonly validation: ValidationResult;
428
447
  readonly emittedAtUtc: string;
448
+ /**
449
+ * Gap A (coord-08 post-Slice-7 integration) — revision IDs authored
450
+ * by the runtime during this apply. Populated for suggest-mode
451
+ * dispatch (tracked insert + delete revisions); omitted for direct-
452
+ * edit. Agents chain into `ai.acceptRevision` / `ai.rejectRevision`
453
+ * to land / discard the proposal without diffing the revision map.
454
+ */
455
+ readonly authoredRevisionIds?: readonly string[];
429
456
  }
430
457
 
431
458
  /* -------------------------------------------------------------------------
@@ -796,6 +796,58 @@ function resolveSurfaceParagraphShading(
796
796
  };
797
797
  }
798
798
 
799
+ /**
800
+ * Strip the verbatim-OOXML `rawXml` round-trip field from
801
+ * `FrameProperties` before forwarding to the public surface. Consumers
802
+ * (L04 paginated-layout placement, L11 render) read the modeled
803
+ * positioning + size fields; `rawXml` exists only so the serializer can
804
+ * merge extension attributes (`w14:*`, `w15:*`, `mc:Ignorable`) back
805
+ * onto the element on export.
806
+ */
807
+ function toSurfaceFrameProperties(
808
+ frame: NonNullable<CanonicalParagraphFormatting["frameProperties"]>,
809
+ ): NonNullable<
810
+ Extract<
811
+ import("../api/public-types.ts").SurfaceBlockSnapshot,
812
+ { kind: "paragraph" }
813
+ >["frameProperties"]
814
+ > {
815
+ const {
816
+ widthTwips,
817
+ heightTwips,
818
+ hRule,
819
+ xTwips,
820
+ yTwips,
821
+ xAlign,
822
+ yAlign,
823
+ hAnchor,
824
+ vAnchor,
825
+ wrap,
826
+ hSpaceTwips,
827
+ vSpaceTwips,
828
+ dropCap,
829
+ lines,
830
+ anchorLock,
831
+ } = frame;
832
+ return {
833
+ ...(widthTwips !== undefined ? { widthTwips } : {}),
834
+ ...(heightTwips !== undefined ? { heightTwips } : {}),
835
+ ...(hRule !== undefined ? { hRule } : {}),
836
+ ...(xTwips !== undefined ? { xTwips } : {}),
837
+ ...(yTwips !== undefined ? { yTwips } : {}),
838
+ ...(xAlign !== undefined ? { xAlign } : {}),
839
+ ...(yAlign !== undefined ? { yAlign } : {}),
840
+ ...(hAnchor !== undefined ? { hAnchor } : {}),
841
+ ...(vAnchor !== undefined ? { vAnchor } : {}),
842
+ ...(wrap !== undefined ? { wrap } : {}),
843
+ ...(hSpaceTwips !== undefined ? { hSpaceTwips } : {}),
844
+ ...(vSpaceTwips !== undefined ? { vSpaceTwips } : {}),
845
+ ...(dropCap !== undefined ? { dropCap } : {}),
846
+ ...(lines !== undefined ? { lines } : {}),
847
+ ...(anchorLock !== undefined ? { anchorLock } : {}),
848
+ };
849
+ }
850
+
799
851
  function resolveSurfaceParagraphFormatting(
800
852
  formatting: CanonicalParagraphFormatting,
801
853
  themeResolver: ThemeColorResolver | undefined,
@@ -1066,6 +1118,9 @@ function createParagraphBlock(
1066
1118
  : {}),
1067
1119
  ...(paragraph.indentation ? { indentation: paragraph.indentation } : {}),
1068
1120
  ...(paragraph.borders ? { borders: paragraph.borders } : {}),
1121
+ ...(paragraph.frameProperties
1122
+ ? { frameProperties: toSurfaceFrameProperties(paragraph.frameProperties) }
1123
+ : {}),
1069
1124
  ...(paragraph.shading
1070
1125
  ? { shading: resolveSurfaceParagraphShading(paragraph.shading, themeResolver) }
1071
1126
  : {}),
@@ -1400,6 +1455,25 @@ function appendInlineSegments(
1400
1455
  state: "locked-preserve-only",
1401
1456
  });
1402
1457
  return { nextCursor: start + 1, lockedFragmentIds: [] };
1458
+ case "page_break":
1459
+ // coord-04 §1.18.5 / coord-03 §11 — `<w:br w:type="page"/>` forces
1460
+ // subsequent content onto a new page. Mirror `column_break`'s
1461
+ // quiet-marker emission (label-based detection). L04 pagination
1462
+ // reads `segment.label === "Page break"` via `hasPageBreak` and
1463
+ // forces `pushPage(block.to)` after placing the carrying block.
1464
+ paragraph.segments.push({
1465
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1466
+ kind: "opaque_inline",
1467
+ from: start,
1468
+ to: start + 1,
1469
+ fragmentId: "",
1470
+ warningId: "",
1471
+ label: "Page break",
1472
+ detail: "Word hard page break marker — pagination forces a new page here.",
1473
+ presentation: "quiet-marker",
1474
+ state: "locked-preserve-only",
1475
+ });
1476
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1403
1477
  case "footnote_ref":
1404
1478
  paragraph.segments.push({
1405
1479
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -2070,6 +2144,8 @@ function summarizePreviewInline(node: InlineNode): string {
2070
2144
  return node.char ? String.fromCodePoint(parseInt(node.char, 16)) : "\uFFFD";
2071
2145
  case "column_break":
2072
2146
  return "[Column break]";
2147
+ case "page_break":
2148
+ return "[Page break]";
2073
2149
  case "chart_preview":
2074
2150
  return "[Embedded chart]";
2075
2151
  case "smartart_preview":
@@ -2376,6 +2452,7 @@ function cloneMarks(marks: TextMark[]): {
2376
2452
  break;
2377
2453
  case "highlight":
2378
2454
  highlightColor = mark.color;
2455
+ supported.push("highlight");
2379
2456
  break;
2380
2457
  case "charSpacing":
2381
2458
  attrs.charSpacing = mark.val;
@@ -838,6 +838,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
838
838
  anchor: publicAnchor,
839
839
  ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
840
840
  ...(params.label ? { label: params.label } : {}),
841
+ ...(params.scopeMetadataFields && params.scopeMetadataFields.length > 0
842
+ ? { metadata: [...params.scopeMetadataFields] }
843
+ : {}),
841
844
  };
842
845
 
843
846
  deps.dispatch({
@@ -34,6 +34,7 @@ import type {
34
34
  RuntimeRenderSnapshot,
35
35
  WorkflowMetadataEntry,
36
36
  WorkflowMetadataPersistence,
37
+ WorkflowScopeMetadataField,
37
38
  WorkflowScopeMode,
38
39
  } from "../../api/public-types.ts";
39
40
 
@@ -68,6 +69,26 @@ export interface CreateScopeFromBlockIdInput {
68
69
  * completeness since `BoundaryAssoc` typing allows them.
69
70
  */
70
71
  readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
72
+ /**
73
+ * Coord-08 §9 / coord-09 §1.13 (A3) — caller-steerable identity
74
+ * strategy for the enumerated scope's `ScopeHandle.stableRef`.
75
+ * Stored as an overlay-scope metadata field (`key: "stableRefHint"`)
76
+ * at creation time; the L08 compiler reads it back during
77
+ * enumeration and honors the requested kind when the strategy is
78
+ * feasible, falling back to its default selection otherwise (see
79
+ * `src/runtime/scopes/enumerate-scopes.ts::stableRefHintForScopeId`
80
+ * for the feasibility matrix).
81
+ *
82
+ * Today's honored values: `"scope-id"` / `"semantic-path"`. The
83
+ * `"bookmark"` + `"runtime-handle"` kinds currently fall back (no
84
+ * bookmark-lookup wired in phase 1; runtime-handle is a transient
85
+ * strategy with no durability meaning).
86
+ */
87
+ readonly stableRefHint?:
88
+ | "scope-id"
89
+ | "bookmark"
90
+ | "semantic-path"
91
+ | "runtime-handle";
71
92
  }
72
93
 
73
94
  export type CreateScopeFromBlockIdResult =
@@ -172,6 +193,16 @@ export function createScopeFromBlockId(
172
193
  if (!anchor) {
173
194
  return { status: "block-not-found", blockId: input.blockId };
174
195
  }
196
+ // Coord-08 §9 — encode stableRefHint as an overlay-scope metadata
197
+ // field so the L08 compiler can read it back at enumeration time.
198
+ const scopeMetadataFields: WorkflowScopeMetadataField[] = [];
199
+ if (input.stableRefHint !== undefined) {
200
+ scopeMetadataFields.push({
201
+ key: "stableRefHint",
202
+ valueType: "string",
203
+ value: input.stableRefHint,
204
+ });
205
+ }
175
206
  const result: AddScopeResult = runtime.addScope({
176
207
  anchor,
177
208
  mode: input.mode,
@@ -180,6 +211,9 @@ export function createScopeFromBlockId(
180
211
  metadata: input.metadata,
181
212
  storyTarget: input.storyTarget,
182
213
  label: input.label,
214
+ ...(scopeMetadataFields.length > 0
215
+ ? { scopeMetadataFields }
216
+ : {}),
183
217
  });
184
218
  return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
185
219
  }
@@ -68,6 +68,34 @@ export async function reconstituteEmbeddedDocuments(
68
68
  }
69
69
  }
70
70
 
71
+ /**
72
+ * SEC-IO-01 (2026-04-23): tamper-verified resolution.
73
+ *
74
+ * Two sources are consulted in order:
75
+ * 1. `hostAdapter.loadEmbeddedDocument(storageReference)` — verified against
76
+ * `entry.sha256`. Mismatch falls through to inline bytes, treated as a
77
+ * storage failure.
78
+ * 2. `entry.inlineBytes` base64 fallback — also verified against
79
+ * `entry.sha256`. If the inline bytes have been tampered with (attacker-
80
+ * modified customXml payload carrying altered bytes with the original
81
+ * declared sha), this check fail-closes: export throws a specific error
82
+ * rather than shipping attacker-chosen bytes into the OPC package.
83
+ *
84
+ * Before the fix, the inline-bytes path returned the decoded payload without
85
+ * verification — a crafted DOCX could inject attacker-controlled bytes into
86
+ * every exported package part whose host-adapter lookup happened to fail.
87
+ */
88
+ class EmbeddedOffloadTamperError extends Error {
89
+ constructor(public readonly entry: { sourcePartPath: string; sha256: string }) {
90
+ super(
91
+ `Embedded offload entry '${entry.sourcePartPath}' failed sha256 integrity ` +
92
+ `check against inline-bytes fallback (expected sha256=${entry.sha256.slice(0, 16)}…). ` +
93
+ `Source customXml payload is tampered.`,
94
+ );
95
+ this.name = "EmbeddedOffloadTamperError";
96
+ }
97
+ }
98
+
71
99
  async function resolveEntryBytes(
72
100
  hostAdapter: EditorHostAdapter | undefined,
73
101
  entry: EmbeddingOffloadEntry,
@@ -89,11 +117,17 @@ async function resolveEntryBytes(
89
117
  }
90
118
  } catch {
91
119
  // Treat identically to `null` — fall through to the inline-
92
- // bytes fallback. The architecture guarantees export never
93
- // fails on storage unavailability.
120
+ // bytes fallback.
94
121
  }
95
122
  }
96
- return decodeInlineBytes(entry.inlineBytes);
123
+ // SEC-IO-01 verification on the inline fallback. Unlike storage
124
+ // unavailability (which is benign), sha mismatch here means the
125
+ // source customXml payload was tampered with. Fail closed.
126
+ const decoded = decodeInlineBytes(entry.inlineBytes);
127
+ if (sha256Hex(decoded) !== entry.sha256) {
128
+ throw new EmbeddedOffloadTamperError(entry);
129
+ }
130
+ return decoded;
97
131
  }
98
132
 
99
133
  /**
@@ -263,6 +263,31 @@ const EMPTY_RESULT: EmbeddedOffloadResult = Object.freeze({
263
263
  // (Buffer) and browser (btoa/atob) contexts. Direct call; the earlier
264
264
  // local `encodeBase64` alias was unnecessary indirection.
265
265
 
266
+ /**
267
+ * SEC-IO-02 (2026-04-23): allowlist check for offload sourcePartPath.
268
+ *
269
+ * Offload entries come from customXml — untrusted content. The export-time
270
+ * `reconstituteEmbeddedDocuments` writes to `entry.sourcePartPath` via
271
+ * `exportSession.replaceOwnedPart`, which accepts arbitrary OPC paths. A
272
+ * malicious `sourcePartPath` could overwrite `/[Content_Types].xml`,
273
+ * `/_rels/.rels`, `/word/document.xml`, or any other sensitive part of the
274
+ * exported package.
275
+ *
276
+ * The offload feature covers EMBEDDED documents only — sub-documents Word
277
+ * stores under `/word/embeddings/embedded{N}.bin` (OLE) or similar. Narrow
278
+ * the allowlist to that canonical prefix. Entries with any other path,
279
+ * any traversal segment, or any control char silently drop.
280
+ */
281
+ const SAFE_EMBEDDING_PATH = /^\/word\/embeddings\/[A-Za-z0-9_.-]+$/;
282
+
283
+ function isSafeSourcePartPath(path: unknown): path is string {
284
+ if (typeof path !== "string") return false;
285
+ if (path.length === 0 || path.length > 256) return false;
286
+ if (path.includes("..")) return false;
287
+ if (path.includes("\0") || path.includes("\n") || path.includes("\r")) return false;
288
+ return SAFE_EMBEDDING_PATH.test(path);
289
+ }
290
+
266
291
  function parseEmbeddingsInline(
267
292
  inline: unknown,
268
293
  ): EmbeddingOffloadEntry[] | undefined {
@@ -293,7 +318,7 @@ function parseEmbeddingsInline(
293
318
  if (
294
319
  typeof r.embeddedDocId !== "string" ||
295
320
  typeof r.relationshipId !== "string" ||
296
- typeof r.sourcePartPath !== "string" ||
321
+ !isSafeSourcePartPath(r.sourcePartPath) ||
297
322
  typeof r.contentType !== "string" ||
298
323
  typeof r.sha256 !== "string" ||
299
324
  typeof r.inlineBytes !== "string" ||
@@ -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);
@@ -6,11 +6,13 @@
6
6
  * digest, walks the OPC parts table, and projects each canonical media
7
7
  * item into a `MediaPreviewDescriptor`.
8
8
  *
9
- * SVG is included in the browser-safe content-type allowlist Chromium
10
- * sandboxes SVGs loaded via `<img src="data:image/svg+xml;base64,…">`
11
- * (no script execution, no external refs), so XSS surface matches PNG.
12
- * Required for CCEP-synthesized chart previews (Stage 0B) and hosts
13
- * that ship `.svg` inside `word/media/`.
9
+ * SVG is EXCLUDED from the content-type allowlist (SEC-UI-03, 2026-04-23).
10
+ * While Chromium's `<img src="data:image/svg+xml;base64,…">` path does block
11
+ * script execution on modern browsers, user-authored SVGs in DOCX `word/media/`
12
+ * can still leak user presence via `<image xlink:href="http://...">` trackers
13
+ * and have historically had CSP-bypass gaps. Chart-preview SVGs (Stage 0B)
14
+ * come from our own renderer via `chart-preview-resolver.ts`, not from
15
+ * `word/media/*`, so dropping SVG here does not regress charts.
14
16
  *
15
17
  * Lives in `src/shell/` so the three `io/**` substrate imports stay
16
18
  * outside the L11 boundary register — package decoding is a shell
@@ -33,7 +35,7 @@ const BROWSER_SAFE_PREVIEW_TYPES = new Set([
33
35
  "image/gif",
34
36
  "image/webp",
35
37
  "image/bmp",
36
- "image/svg+xml",
38
+ // SVG intentionally EXCLUDED — see SEC-UI-03 header comment.
37
39
  ]);
38
40
 
39
41
  export function buildMediaPreviews(
@@ -3321,6 +3321,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3321
3321
  pasteFragmentParser={SHELL_PASTE_FRAGMENT_PARSER}
3322
3322
  runtimeSearchDocument={api.runtime.search.searchDocument}
3323
3323
  runtimeGetTableSelectionDescriptor={api.runtime.table.getSelectionDescriptor}
3324
+ scopeTagRegistryFactory={api.runtime.workflow.createScopeTagRegistry}
3324
3325
  snapshot={snapshot}
3325
3326
  canonicalDocument={canonicalDocument}
3326
3327
  documentNavigation={documentNavigation}
@@ -84,6 +84,17 @@ export interface EditorSurfaceControllerProps {
84
84
  runtimeGetTableSelectionDescriptor?: (
85
85
  state: import("prosemirror-state").EditorState,
86
86
  ) => TableSelectionDescriptor | null;
87
+ /**
88
+ * Coord-10 L11-4 — forwarded to `TwProseMirrorSurface`. Threaded
89
+ * from `api.runtime.workflow.createScopeTagRegistry` (shipped by
90
+ * L06 in react-refactor `534f2b97`). Replaces the pre-L06-catalog
91
+ * pragmatic public-types re-export (refactor/11 §4.17, 2026-04-24
92
+ * `7a2d2fc0`). When absent, `TwProseMirrorSurface` falls back to
93
+ * the public-types re-export for test / headless mount back-compat.
94
+ */
95
+ scopeTagRegistryFactory?: () => import(
96
+ "../api/public-types.ts"
97
+ ).ScopeTagRegistry;
87
98
  onPasteApplied?: (meta: {
88
99
  segmentCount: number;
89
100
  charCount: number;
@@ -1,8 +1,8 @@
1
- import type { SelectionSnapshot } from "../../api/public-types";
2
1
  import {
2
+ type SelectionSnapshot,
3
3
  createPublicNodeAnchor,
4
4
  createPublicRangeAnchor,
5
- } from "../../core/selection/anchor-conversion.ts";
5
+ } from "../../api/public-types";
6
6
 
7
7
  /**
8
8
  * Headless-UI-side `createSelectionSnapshot` that produces the **public**
@@ -1,8 +1,8 @@
1
- import type {
2
- StyleCatalogSnapshot,
3
- WorkflowBlockedCommandReason,
1
+ import {
2
+ CAPABILITY_BY_ID,
3
+ type StyleCatalogSnapshot,
4
+ type WorkflowBlockedCommandReason,
4
5
  } from "../api/public-types.ts";
5
- import { CAPABILITY_BY_ID } from "../runtime/editor-surface/capabilities.ts";
6
6
 
7
7
  export interface ShortcutKeyInput {
8
8
  key: string;