@beyondwork/docx-react-component 1.0.83 → 1.0.85

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/api/internal/build-ref-projections.ts +3 -0
  3. package/src/api/public-types.ts +86 -4
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/content.ts +148 -1
  6. package/src/api/v3/runtime/formatting.ts +41 -0
  7. package/src/api/v3/runtime/review.ts +98 -0
  8. package/src/api/v3/runtime/workflow.ts +154 -6
  9. package/src/core/commands/index.ts +81 -25
  10. package/src/core/state/editor-state.ts +15 -0
  11. package/src/io/export/serialize-main-document.ts +72 -6
  12. package/src/io/ooxml/header-footer-reference.ts +38 -0
  13. package/src/io/ooxml/parse-headers-footers.ts +11 -23
  14. package/src/io/ooxml/parse-main-document.ts +7 -10
  15. package/src/io/ooxml/workflow-payload-validator.ts +24 -0
  16. package/src/io/ooxml/workflow-payload.ts +12 -0
  17. package/src/model/canonical-document.ts +9 -0
  18. package/src/model/review/comment-types.ts +2 -0
  19. package/src/runtime/document-runtime.ts +718 -68
  20. package/src/runtime/formatting/field/resolver.ts +73 -8
  21. package/src/runtime/layout/layout-engine-version.ts +31 -12
  22. package/src/runtime/layout/paginated-layout-engine.ts +18 -11
  23. package/src/runtime/layout/public-facet.ts +119 -16
  24. package/src/runtime/layout/resolve-page-fields.ts +68 -6
  25. package/src/runtime/layout/resolve-page-previews.ts +1 -1
  26. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  27. package/src/runtime/scopes/action-validation.ts +54 -45
  28. package/src/runtime/scopes/workflow-overlap.ts +41 -9
  29. package/src/runtime/suggestions-snapshot.ts +24 -0
  30. package/src/runtime/surface-projection.ts +59 -2
  31. package/src/runtime/workflow/coordinator.ts +66 -14
  32. package/src/runtime/workflow/scope-writer.ts +83 -5
  33. package/src/shell/ref-commands.ts +3 -354
  34. package/src/shell/session-bootstrap.ts +10 -0
  35. package/src/ui/WordReviewEditor.tsx +99 -9
  36. package/src/ui/editor-command-bag.ts +3 -1
  37. package/src/ui/headless/revision-decoration-model.ts +13 -0
  38. package/src/ui/headless/selection-tool-types.ts +2 -0
  39. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
  40. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
  42. package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
  43. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
  44. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
  45. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
  46. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
  47. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
  48. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
  49. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
  50. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
  51. package/src/ui-tailwind/review-workspace/types.ts +3 -2
  52. package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
  53. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -1
  54. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +2 -1
  55. package/src/ui-tailwind/tw-review-workspace.tsx +18 -2
@@ -2,7 +2,8 @@
2
2
  * @endStateApi v3 — `runtime.workflow` family.
3
3
  *
4
4
  * queryScopes (live) / getMarkup (live) / getGuard (live) /
5
- * createScope (live-with-adapter) / attachMetadata (live-with-adapter) /
5
+ * createScope (live-with-adapter) / setScopeGuardPolicy (live) /
6
+ * attachMetadata (live-with-adapter) /
6
7
  * getVisibilityPolicy · getVisibilityPolicies · setVisibilityPolicy ·
7
8
  * clearVisibilityPolicy (live — W10 state-classes X1) /
8
9
  * scopeTags (live — coord-10 L11-4 tag-catalog read).
@@ -14,6 +15,8 @@ import type {
14
15
  EditorStoryTarget,
15
16
  OverlayKind,
16
17
  OverlayVisibilityPolicy,
18
+ ScopeVisibility,
19
+ WorkflowScopeGuardPolicy,
17
20
  WorkflowMarkupModePolicy,
18
21
  } from "../../public-types.ts";
19
22
  import { emitUxResponse } from "../_ux-response.ts";
@@ -82,6 +85,13 @@ export interface CreateScopeInput {
82
85
  readonly blockId: string;
83
86
  readonly mode?: "edit" | "suggest" | "comment" | "view";
84
87
  readonly label?: string;
88
+ /** Per-scope visibility. Controls chrome/query presentation only. */
89
+ readonly visibility?: ScopeVisibility;
90
+ /**
91
+ * Explicit edit-enforcement posture. Use `"insert-only"` for
92
+ * template-slot allowlists; use `"read-only"` to lock the scope range.
93
+ */
94
+ readonly guardPolicy?: WorkflowScopeGuardPolicy;
85
95
  /**
86
96
  * Coord-06 §13d — per-scope edge stickiness. Defaults to
87
97
  * `{ start: 1, end: -1 }` (greedy — absorbs boundary inserts, the
@@ -112,10 +122,24 @@ export interface CreateScopeInput {
112
122
  | "runtime-handle";
113
123
  }
114
124
 
115
- export interface CreateScopeResult {
116
- readonly scopeId: string;
117
- readonly status: "created" | "block-not-found";
118
- }
125
+ export type CreateScopeResult =
126
+ | { readonly scopeId: string; readonly status: "created" }
127
+ | { readonly scopeId: ""; readonly status: "block-not-found" }
128
+ | {
129
+ readonly scopeId: "";
130
+ readonly status: "range-invalid";
131
+ readonly reason:
132
+ | "range-exceeds-story-length"
133
+ | "non-paragraph-target"
134
+ | "empty-document";
135
+ readonly from: number;
136
+ readonly to: number;
137
+ readonly storyLength: number;
138
+ readonly blockIndex?: number;
139
+ readonly blockKind?: string;
140
+ readonly message: string;
141
+ readonly nextStep: string;
142
+ };
119
143
 
120
144
  /**
121
145
  * Input for `createScopeFromAnchor` — sub-block marker-backed scope.
@@ -144,6 +168,8 @@ export interface CreateScopeFromAnchorInput {
144
168
  };
145
169
  readonly mode?: "edit" | "suggest" | "comment" | "view";
146
170
  readonly label?: string;
171
+ readonly visibility?: ScopeVisibility;
172
+ readonly guardPolicy?: WorkflowScopeGuardPolicy;
147
173
  readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
148
174
  readonly stableRefHint?:
149
175
  | "scope-id"
@@ -174,6 +200,22 @@ export type CreateScopeFromAnchorResult =
174
200
  readonly nextStep: string;
175
201
  };
176
202
 
203
+ export interface SetScopeGuardPolicyInput {
204
+ readonly scopeId: string;
205
+ readonly guardPolicy: WorkflowScopeGuardPolicy;
206
+ }
207
+
208
+ export type SetScopeGuardPolicyResult =
209
+ | {
210
+ readonly status: "updated";
211
+ readonly scopeId: string;
212
+ readonly guardPolicy: WorkflowScopeGuardPolicy;
213
+ }
214
+ | {
215
+ readonly status: "scope-not-found";
216
+ readonly scopeId: string;
217
+ };
218
+
177
219
  export const createScopeMetadata: ApiV3FnMetadata = {
178
220
  name: "runtime.workflow.createScope",
179
221
  status: "live-with-adapter",
@@ -216,6 +258,51 @@ export const createScopeFromAnchorMetadata: ApiV3FnMetadata = {
216
258
  "§Runtime API § runtime.workflow.createScopeFromAnchor. Companion to createScope({blockId}) for sub-block ranges. Consumes from/to once to plant inline scope_marker_start/end; the returned scopeId is the durable reference (KI-P9).",
217
259
  };
218
260
 
261
+ export const getScopeGuardPolicyMetadata: ApiV3FnMetadata = {
262
+ name: "runtime.workflow.getScopeGuardPolicy",
263
+ status: "live",
264
+ sourceLayer: "workflow-review",
265
+ liveEvidence: {
266
+ runnerTest: "test/api/v3/workflow-create-scope-live.test.ts",
267
+ commit: "guard-policy-setter",
268
+ },
269
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
270
+ agentMetadata: {
271
+ readOrMutate: "read",
272
+ boundedScope: "scope",
273
+ auditCategory: "scope-policy",
274
+ },
275
+ stateClass: "A-canonical",
276
+ persistsTo: "customXml",
277
+ broadcastsVia: "crdt",
278
+ rwdReference: "§Runtime API § runtime.workflow.getScopeGuardPolicy",
279
+ };
280
+
281
+ export const setScopeGuardPolicyMetadata: ApiV3FnMetadata = {
282
+ name: "runtime.workflow.setScopeGuardPolicy",
283
+ status: "live",
284
+ sourceLayer: "workflow-review",
285
+ liveEvidence: {
286
+ runnerTest: "test/api/v3/workflow-create-scope-live.test.ts",
287
+ commit: "guard-policy-setter",
288
+ },
289
+ uxIntent: {
290
+ uiVisible: true,
291
+ expectsUxResponse: "inline-change",
292
+ expectedDelta:
293
+ "scope edit-enforcement policy updates and interaction guard recalculates",
294
+ },
295
+ agentMetadata: {
296
+ readOrMutate: "mutate",
297
+ boundedScope: "scope",
298
+ auditCategory: "scope-policy",
299
+ },
300
+ stateClass: "A-canonical",
301
+ persistsTo: "customXml",
302
+ broadcastsVia: "crdt",
303
+ rwdReference: "§Runtime API § runtime.workflow.setScopeGuardPolicy",
304
+ };
305
+
219
306
  export interface AttachMetadataInput {
220
307
  readonly scopeId: string;
221
308
  readonly metadataId: string;
@@ -476,6 +563,8 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
476
563
  blockId: input.blockId,
477
564
  mode: input.mode,
478
565
  label: input.label,
566
+ ...(input.visibility ? { visibility: input.visibility } : {}),
567
+ ...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
479
568
  ...(input.assoc ? { assoc: input.assoc } : {}),
480
569
  ...(input.stableRefHint
481
570
  ? { stableRefHint: input.stableRefHint }
@@ -493,11 +582,68 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
493
582
  : undefined,
494
583
  });
495
584
  if (adapterResult.status !== "created") {
496
- return { scopeId: "", status: adapterResult.status };
585
+ if (adapterResult.status === "block-not-found") {
586
+ return { scopeId: "", status: "block-not-found" };
587
+ }
588
+ return {
589
+ scopeId: "",
590
+ status: "range-invalid",
591
+ reason: adapterResult.reason,
592
+ from: adapterResult.from,
593
+ to: adapterResult.to,
594
+ storyLength: adapterResult.storyLength,
595
+ ...(adapterResult.blockIndex !== undefined
596
+ ? { blockIndex: adapterResult.blockIndex }
597
+ : {}),
598
+ ...(adapterResult.blockKind !== undefined
599
+ ? { blockKind: adapterResult.blockKind }
600
+ : {}),
601
+ message: adapterResult.message,
602
+ nextStep: adapterResult.nextStep,
603
+ };
497
604
  }
498
605
  return { scopeId: adapterResult.scopeId, status: "created" };
499
606
  },
500
607
 
608
+ getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy {
609
+ // @endStateApi — live.
610
+ return runtime.getScopeGuardPolicy(scopeId);
611
+ },
612
+
613
+ setScopeGuardPolicy(
614
+ input: SetScopeGuardPolicyInput,
615
+ ): SetScopeGuardPolicyResult {
616
+ // @endStateApi — live. Mutates the scope's explicit guard axis in the
617
+ // workflow overlay. Visibility stays presentation-only.
618
+ const overlay = runtime.getWorkflowOverlay();
619
+ const existingScope = overlay?.scopes.find(
620
+ (scope) => scope.scopeId === input.scopeId,
621
+ );
622
+ if (!existingScope) {
623
+ return { status: "scope-not-found", scopeId: input.scopeId };
624
+ }
625
+ runtime.setScopeGuardPolicy(input.scopeId, input.guardPolicy);
626
+ emitUxResponse(runtime, {
627
+ apiFn: setScopeGuardPolicyMetadata.name,
628
+ intent: setScopeGuardPolicyMetadata.uxIntent.expectedDelta ?? "",
629
+ mockOrLive: "live",
630
+ uiVisible: true,
631
+ expectedDelta: setScopeGuardPolicyMetadata.uxIntent.expectedDelta,
632
+ actualDelta: {
633
+ kind: "inline-change",
634
+ payload: {
635
+ scopeId: input.scopeId,
636
+ guardPolicy: input.guardPolicy,
637
+ },
638
+ },
639
+ });
640
+ return {
641
+ status: "updated",
642
+ scopeId: input.scopeId,
643
+ guardPolicy: input.guardPolicy,
644
+ };
645
+ },
646
+
501
647
  createScopeFromAnchor(
502
648
  input: CreateScopeFromAnchorInput,
503
649
  ): CreateScopeFromAnchorResult {
@@ -510,6 +656,8 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
510
656
  anchor: input.anchor,
511
657
  mode: input.mode,
512
658
  label: input.label,
659
+ ...(input.visibility ? { visibility: input.visibility } : {}),
660
+ ...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
513
661
  ...(input.assoc ? { assoc: input.assoc } : {}),
514
662
  ...(input.stableRefHint
515
663
  ? { stableRefHint: input.stableRefHint }
@@ -58,7 +58,9 @@ import {
58
58
  } from "./text-commands.ts";
59
59
  import type {
60
60
  BlockNode,
61
+ InlineNode,
61
62
  MutableCanonicalDocument,
63
+ ParagraphNode,
62
64
  RevisionRecord as CanonicalRevisionRecord,
63
65
  } from "../../model/canonical-document.ts";
64
66
  import { remapCommentThreads } from "../../review/store/comment-remapping.ts";
@@ -2314,39 +2316,93 @@ function isSingleParagraphSuggestingRange(
2314
2316
  to: number,
2315
2317
  ): boolean {
2316
2318
  const ranges: Array<{ start: number; end: number }> = [];
2317
- let cursor = 0;
2318
- let previousWasParagraph = false;
2319
+ collectSuggestingParagraphRanges(
2320
+ document.content.children,
2321
+ 0,
2322
+ ranges,
2323
+ true,
2324
+ );
2325
+
2326
+ return ranges.some((range) => from >= range.start && to <= range.end);
2327
+ }
2328
+
2329
+ function collectSuggestingParagraphRanges(
2330
+ blocks: readonly BlockNode[],
2331
+ startCursor: number,
2332
+ output: Array<{ start: number; end: number }>,
2333
+ addRootParagraphBoundaries: boolean,
2334
+ ): number {
2335
+ let cursor = startCursor;
2336
+
2337
+ for (let index = 0; index < blocks.length; index += 1) {
2338
+ const block = blocks[index];
2339
+ if (!block) {
2340
+ continue;
2341
+ }
2319
2342
 
2320
- for (const block of document.content.children) {
2321
2343
  if (block.type === "paragraph") {
2322
- if (previousWasParagraph) {
2323
- cursor += 1;
2324
- }
2325
2344
  const start = cursor;
2326
- cursor += block.children.reduce<number>((size, child) => {
2327
- if (child.type === "text") {
2328
- return size + child.text.length;
2329
- }
2330
- if (child.type === "hyperlink") {
2331
- return size + child.children.reduce<number>((childSize, entry) => {
2332
- if (entry.type === "text") {
2333
- return childSize + entry.text.length;
2334
- }
2335
- return childSize + 1;
2336
- }, 0);
2345
+ cursor += paragraphLogicalLength(block);
2346
+ output.push({ start, end: cursor });
2347
+ } else if (block.type === "table") {
2348
+ for (const row of block.rows) {
2349
+ for (const cell of row.cells) {
2350
+ cursor = collectSuggestingParagraphRanges(
2351
+ cell.children,
2352
+ cursor,
2353
+ output,
2354
+ false,
2355
+ );
2337
2356
  }
2338
- return size + 1;
2339
- }, 0);
2340
- ranges.push({ start, end: cursor });
2341
- previousWasParagraph = true;
2342
- continue;
2357
+ }
2358
+ } else if (block.type === "sdt") {
2359
+ cursor = collectSuggestingParagraphRanges(
2360
+ block.children,
2361
+ cursor,
2362
+ output,
2363
+ false,
2364
+ );
2365
+ } else {
2366
+ cursor += 1;
2343
2367
  }
2344
2368
 
2345
- cursor += 1;
2346
- previousWasParagraph = false;
2369
+ if (
2370
+ addRootParagraphBoundaries &&
2371
+ index < blocks.length - 1 &&
2372
+ blocks[index + 1]?.type === "paragraph"
2373
+ ) {
2374
+ cursor += 1;
2375
+ }
2347
2376
  }
2348
2377
 
2349
- return ranges.some((range) => from >= range.start && to <= range.end);
2378
+ return cursor;
2379
+ }
2380
+
2381
+ function paragraphLogicalLength(paragraph: ParagraphNode): number {
2382
+ return paragraph.children.reduce<number>(
2383
+ (size, child) => size + inlineLogicalLength(child),
2384
+ 0,
2385
+ );
2386
+ }
2387
+
2388
+ function inlineLogicalLength(node: InlineNode): number {
2389
+ switch (node.type) {
2390
+ case "text":
2391
+ return node.text.length;
2392
+ case "hyperlink":
2393
+ case "field":
2394
+ return node.children.reduce<number>(
2395
+ (size, child) => size + inlineLogicalLength(child as InlineNode),
2396
+ 0,
2397
+ );
2398
+ case "bookmark_start":
2399
+ case "bookmark_end":
2400
+ case "scope_marker_start":
2401
+ case "scope_marker_end":
2402
+ return 0;
2403
+ default:
2404
+ return 1;
2405
+ }
2350
2406
  }
2351
2407
 
2352
2408
  function applySuggestingInsert(
@@ -839,6 +839,21 @@ export function normalizeCommentThreadRecord(value: unknown): CommentThreadRecor
839
839
  typeof record.metadata.rootParaId === "string"
840
840
  ? record.metadata.rootParaId
841
841
  : undefined,
842
+ linkedRevisionId:
843
+ typeof record.metadata.linkedRevisionId === "string"
844
+ ? record.metadata.linkedRevisionId
845
+ : undefined,
846
+ detachedReason:
847
+ record.metadata.detachedReason === "incomplete-markers" ||
848
+ record.metadata.detachedReason === "multi-paragraph" ||
849
+ record.metadata.detachedReason === "opaque-region" ||
850
+ record.metadata.detachedReason === "revision-overlap"
851
+ ? record.metadata.detachedReason
852
+ : undefined,
853
+ actionabilityNote:
854
+ typeof record.metadata.actionabilityNote === "string"
855
+ ? record.metadata.actionabilityNote
856
+ : undefined,
842
857
  }
843
858
  : undefined,
844
859
  };
@@ -89,6 +89,14 @@ interface SerializationState {
89
89
  usedTextIds: Set<string>;
90
90
  /** Deterministic PRNG-less counter used when we mint fresh ids. */
91
91
  mintedParaIdCounter: number;
92
+ /**
93
+ * Scope markers export as OOXML bookmarks. `w:bookmarkStart/@w:id` is a
94
+ * decimal number in WordprocessingML, so scope ids cannot be written into
95
+ * it directly.
96
+ */
97
+ usedBookmarkIds: Set<string>;
98
+ scopeBookmarkIds: Map<string, string>;
99
+ nextScopeBookmarkId: number;
92
100
  }
93
101
 
94
102
  interface InlineSerializationResult {
@@ -117,6 +125,61 @@ function serializePerformanceNow(): number {
117
125
  return Date.now();
118
126
  }
119
127
 
128
+ function collectNumericBookmarkIds(content: DocumentRootNode): Set<string> {
129
+ const used = new Set<string>();
130
+ const visit = (node: unknown): void => {
131
+ if (!node || typeof node !== "object") return;
132
+ const typed = node as {
133
+ type?: string;
134
+ bookmarkId?: unknown;
135
+ children?: unknown;
136
+ rows?: unknown;
137
+ cells?: unknown;
138
+ };
139
+ if (
140
+ (typed.type === "bookmark_start" || typed.type === "bookmark_end") &&
141
+ typeof typed.bookmarkId === "string"
142
+ ) {
143
+ const normalized = normalizeDecimalBookmarkId(typed.bookmarkId);
144
+ if (normalized !== null) used.add(normalized);
145
+ }
146
+
147
+ if (Array.isArray(typed.children)) {
148
+ for (const child of typed.children) visit(child);
149
+ }
150
+ if (Array.isArray(typed.rows)) {
151
+ for (const row of typed.rows) visit(row);
152
+ }
153
+ if (Array.isArray(typed.cells)) {
154
+ for (const cell of typed.cells) visit(cell);
155
+ }
156
+ };
157
+
158
+ visit(content);
159
+ return used;
160
+ }
161
+
162
+ function normalizeDecimalBookmarkId(id: string): string | null {
163
+ if (!/^\d+$/u.test(id)) return null;
164
+ const stripped = id.replace(/^0+/u, "");
165
+ return stripped.length > 0 ? stripped : "0";
166
+ }
167
+
168
+ function scopeBookmarkIdFor(state: SerializationState, scopeId: string): string {
169
+ const existing = state.scopeBookmarkIds.get(scopeId);
170
+ if (existing !== undefined) return existing;
171
+
172
+ let next = state.nextScopeBookmarkId;
173
+ while (state.usedBookmarkIds.has(String(next))) {
174
+ next += 1;
175
+ }
176
+ const bookmarkId = String(next);
177
+ state.nextScopeBookmarkId = next + 1;
178
+ state.usedBookmarkIds.add(bookmarkId);
179
+ state.scopeBookmarkIds.set(scopeId, bookmarkId);
180
+ return bookmarkId;
181
+ }
182
+
120
183
  export function serializeMainDocument(
121
184
  content: DocumentRootNode,
122
185
  preservation: PreservationStore = { opaqueFragments: {}, packageParts: {} },
@@ -150,6 +213,9 @@ export function serializeMainDocument(
150
213
  usedParaIds: new Set<string>(),
151
214
  usedTextIds: new Set<string>(),
152
215
  mintedParaIdCounter: 0,
216
+ usedBookmarkIds: collectNumericBookmarkIds(content),
217
+ scopeBookmarkIds: new Map<string, string>(),
218
+ nextScopeBookmarkId: 100000,
153
219
  };
154
220
  const suffix = `</w:body>\n</w:document>`;
155
221
  const bodyPieces: string[] = [];
@@ -679,9 +745,9 @@ function serializeTableInlineNode(
679
745
  return `<w:bookmarkEnd w:id="${escapeXmlAttribute(node.bookmarkId)}"/>`;
680
746
  case "scope_marker_start": {
681
747
  // S1 — scope markers export as w:bookmarkStart with the reserved
682
- // `bw:scope:` name prefix. The synthetic w:id is keyed on scopeId so
683
- // the matching end element references the same id.
684
- const bkId = `scope-${node.scopeId}`;
748
+ // `bw:scope:` name prefix. The synthetic w:id is numeric because
749
+ // WordprocessingML bookmark ids are ST_DecimalNumber values.
750
+ const bkId = scopeBookmarkIdFor(state, node.scopeId);
685
751
  const name = `${SCOPE_MARKER_BOOKMARK_PREFIX}${node.scopeId}`;
686
752
  return (
687
753
  `<w:bookmarkStart w:id="${escapeXmlAttribute(bkId)}"` +
@@ -689,7 +755,7 @@ function serializeTableInlineNode(
689
755
  );
690
756
  }
691
757
  case "scope_marker_end": {
692
- const bkId = `scope-${node.scopeId}`;
758
+ const bkId = scopeBookmarkIdFor(state, node.scopeId);
693
759
  return `<w:bookmarkEnd w:id="${escapeXmlAttribute(bkId)}"/>`;
694
760
  }
695
761
  case "footnote_ref": {
@@ -1258,7 +1324,7 @@ function serializeInlineNode(
1258
1324
  case "scope_marker_start": {
1259
1325
  // S1 — mirror the bookmark_start shape with the reserved `bw:scope:`
1260
1326
  // name prefix. See serializeInline() above for the same convention.
1261
- const bkId = `scope-${node.scopeId}`;
1327
+ const bkId = scopeBookmarkIdFor(state, node.scopeId);
1262
1328
  const name = `${SCOPE_MARKER_BOOKMARK_PREFIX}${node.scopeId}`;
1263
1329
  const xml =
1264
1330
  `<w:bookmarkStart w:id="${escapeXmlAttribute(bkId)}"` +
@@ -1269,7 +1335,7 @@ function serializeInlineNode(
1269
1335
  return { xml, cursor, boundaries };
1270
1336
  }
1271
1337
  case "scope_marker_end": {
1272
- const bkId = `scope-${node.scopeId}`;
1338
+ const bkId = scopeBookmarkIdFor(state, node.scopeId);
1273
1339
  const xml = `<w:bookmarkEnd w:id="${escapeXmlAttribute(bkId)}"/>`;
1274
1340
  const boundaries = new Map<number, number>();
1275
1341
  boundaries.set(cursor, xmlOffset);
@@ -0,0 +1,38 @@
1
+ import type { HeaderFooterVariant } from "../../model/canonical-document.ts";
2
+
3
+ interface HeaderFooterReferenceElement {
4
+ attributes: Record<string, string>;
5
+ }
6
+
7
+ export interface ParsedHeaderFooterReferenceAttributes {
8
+ variant: HeaderFooterVariant;
9
+ relationshipId: string;
10
+ }
11
+
12
+ export function readHeaderFooterReferenceAttributes(
13
+ element: HeaderFooterReferenceElement,
14
+ ): ParsedHeaderFooterReferenceAttributes | undefined {
15
+ const relationshipId =
16
+ element.attributes["r:id"] ??
17
+ element.attributes["r:Id"] ??
18
+ element.attributes.id ??
19
+ element.attributes.Id;
20
+ if (!relationshipId) {
21
+ return undefined;
22
+ }
23
+
24
+ return {
25
+ variant: toHeaderFooterVariant(element.attributes["w:type"] ?? element.attributes.type),
26
+ relationshipId,
27
+ };
28
+ }
29
+
30
+ export function toHeaderFooterVariant(raw: string | undefined): HeaderFooterVariant {
31
+ if (raw === "first") {
32
+ return "first";
33
+ }
34
+ if (raw === "even") {
35
+ return "even";
36
+ }
37
+ return "default";
38
+ }
@@ -20,6 +20,7 @@ import { classifyFieldInstruction } from "./parse-fields.ts";
20
20
  import { isSafeTableFieldInstruction } from "./table-opaque-preservation.ts";
21
21
  import { parseXmlWithOffsets as parseXml } from "./xml-parser.ts";
22
22
  import { localName, readStringAttr } from "./xml-attr-helpers.ts";
23
+ import { readHeaderFooterReferenceAttributes } from "./header-footer-reference.ts";
23
24
  import {
24
25
  readCellBorders,
25
26
  readCellCnfStyle,
@@ -200,40 +201,27 @@ function extractSectPrRefs(
200
201
  const name = localName(child.name);
201
202
  if (name === "headerReference" || name === "footerReference") {
202
203
  const kind: "header" | "footer" = name === "headerReference" ? "header" : "footer";
203
- const rawType =
204
- readStringAttr(child, "w:type") ?? "default";
205
- const variant = toHeaderFooterVariant(rawType);
206
- const relationshipId =
207
- child.attributes["r:id"] ??
208
- child.attributes["r:Id"] ??
209
- child.attributes.id ??
210
- child.attributes.Id ??
211
- "";
212
-
213
- if (relationshipId) {
204
+ const ref = readHeaderFooterReferenceAttributes(child);
205
+
206
+ if (ref) {
214
207
  // Avoid duplicates (multiple sectPr may reference same header)
215
- const dedupeKey = `${kind}:${variant}:${relationshipId}`;
208
+ const dedupeKey = `${kind}:${ref.variant}:${ref.relationshipId}`;
216
209
  const alreadyAdded = refs.some(
217
210
  (ref) => `${ref.kind}:${ref.variant}:${ref.relationshipId}` === dedupeKey,
218
211
  );
219
212
  if (!alreadyAdded) {
220
- refs.push({ variant, relationshipId, kind, sectionIndex });
213
+ refs.push({
214
+ variant: ref.variant,
215
+ relationshipId: ref.relationshipId,
216
+ kind,
217
+ sectionIndex,
218
+ });
221
219
  }
222
220
  }
223
221
  }
224
222
  }
225
223
  }
226
224
 
227
- function toHeaderFooterVariant(raw: string): HeaderFooterVariant {
228
- if (raw === "first") {
229
- return "first";
230
- }
231
- if (raw === "even") {
232
- return "even";
233
- }
234
- return "default";
235
- }
236
-
237
225
  function parseHdrFtrXml(
238
226
  xml: string,
239
227
  rootLocalName: "hdr" | "ftr",
@@ -21,8 +21,6 @@ import type {
21
21
  PageMargins,
22
22
  ColumnProperties,
23
23
  PageNumbering,
24
- HeaderFooterReference,
25
- HeaderFooterVariant,
26
24
  SectionDocumentGrid,
27
25
  SectionLineNumbering,
28
26
  SectionPageBorders,
@@ -45,6 +43,7 @@ import { parseComplexContentXml, type ChartPartLookup } from "./parse-complex-co
45
43
  import { parseShapeXml, parseVmlXml } from "./parse-shapes.ts";
46
44
  import { parseObject } from "./parse-object.ts";
47
45
  import { parseDrawingFrame } from "./parse-drawing.ts";
46
+ import { readHeaderFooterReferenceAttributes } from "./header-footer-reference.ts";
48
47
  import { readFrameProperties } from "./parse-paragraph-formatting.ts";
49
48
  import { tableRequiresOpaquePreservation } from "./table-opaque-preservation.ts";
50
49
  import { classifyFieldInstruction } from "./parse-fields.ts";
@@ -3756,20 +3755,18 @@ export function parseSectionPropertiesFromElement(
3756
3755
  break;
3757
3756
  }
3758
3757
  case "headerReference": {
3759
- const variant = child.attributes["w:type"] as HeaderFooterVariant | undefined;
3760
- const rId = child.attributes["r:id"];
3761
- if (variant && rId) {
3758
+ const ref = readHeaderFooterReferenceAttributes(child);
3759
+ if (ref) {
3762
3760
  if (!props.headerReferences) props.headerReferences = [];
3763
- props.headerReferences.push({ variant, relationshipId: rId });
3761
+ props.headerReferences.push(ref);
3764
3762
  }
3765
3763
  break;
3766
3764
  }
3767
3765
  case "footerReference": {
3768
- const variant = child.attributes["w:type"] as HeaderFooterVariant | undefined;
3769
- const rId = child.attributes["r:id"];
3770
- if (variant && rId) {
3766
+ const ref = readHeaderFooterReferenceAttributes(child);
3767
+ if (ref) {
3771
3768
  if (!props.footerReferences) props.footerReferences = [];
3772
- props.footerReferences.push({ variant, relationshipId: rId });
3769
+ props.footerReferences.push(ref);
3773
3770
  }
3774
3771
  break;
3775
3772
  }
@@ -40,6 +40,8 @@ export interface ValidatorIssue {
40
40
 
41
41
  const OVERLAY_PERSISTENCE_VALUES = ["internal", "external"] as const;
42
42
  const SCOPE_PERSISTENCE_VALUES = ["internal", "external", "inherit"] as const;
43
+ const SCOPE_VISIBILITY_VALUES = ["visible", "hidden", "invisible"] as const;
44
+ const SCOPE_GUARD_POLICY_VALUES = ["none", "insert-only", "read-only"] as const;
43
45
 
44
46
  export function validateWorkflowPayloadEnvelope(xml: string): ValidatorIssue[] {
45
47
  const issues: ValidatorIssue[] = [];
@@ -82,6 +84,28 @@ export function validateWorkflowPayloadEnvelope(xml: string): ValidatorIssue[] {
82
84
  for (const scope of findAll(xml, /<bw:scope\b([^>]*?)>([\s\S]*?)<\/bw:scope>/gu)) {
83
85
  const scopeAttrs = parseAttrs(scope.attrs);
84
86
  const scopeId = scopeAttrs.id ?? "?";
87
+ if (
88
+ scopeAttrs.visibility !== undefined &&
89
+ !(SCOPE_VISIBILITY_VALUES as readonly string[]).includes(scopeAttrs.visibility)
90
+ ) {
91
+ issues.push({
92
+ code: "unknown_enum_value",
93
+ path: `bw:scope[@id='${scopeId}']/@visibility`,
94
+ value: scopeAttrs.visibility,
95
+ severity: "warning",
96
+ });
97
+ }
98
+ if (
99
+ scopeAttrs.guardPolicy !== undefined &&
100
+ !(SCOPE_GUARD_POLICY_VALUES as readonly string[]).includes(scopeAttrs.guardPolicy)
101
+ ) {
102
+ issues.push({
103
+ code: "unknown_enum_value",
104
+ path: `bw:scope[@id='${scopeId}']/@guardPolicy`,
105
+ value: scopeAttrs.guardPolicy,
106
+ severity: "warning",
107
+ });
108
+ }
85
109
  let scopePersistence: string | undefined = scopeAttrs.metadataPersistence;
86
110
  if (scopePersistence !== undefined) {
87
111
  if (