@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -17,7 +17,10 @@ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
17
17
  import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
18
18
  import type { EditorStoryTarget } from "../../api/public-types";
19
19
  import type {
20
+ PublicBlockFragment,
20
21
  PublicPageNode,
22
+ PublicPageRegion,
23
+ PublicRegionBlock,
21
24
  WordReviewEditorLayoutFacet,
22
25
  } from "../layout/public-facet.ts";
23
26
  import type {
@@ -305,6 +308,7 @@ function buildPage(
305
308
  zoom,
306
309
  "header",
307
310
  page.stories.header,
311
+ facet,
308
312
  );
309
313
  }
310
314
  if (page.stories.footer) {
@@ -314,9 +318,18 @@ function buildPage(
314
318
  zoom,
315
319
  "footer",
316
320
  page.stories.footer,
321
+ facet,
317
322
  );
318
323
  }
319
324
 
325
+ const footnoteRegions = buildFootnoteRegions(page, topPx, zoom, facet);
326
+ if (footnoteRegions.length > 0) {
327
+ regions.footnotes = footnoteRegions;
328
+ }
329
+ // Endnotes intentionally skipped — per-page endnote projection is not
330
+ // populated; endnotes use document-end placement via
331
+ // `facet.getDocumentEndnoteBlocks()`.
332
+
320
333
  const chromeReservations: PageChromeReservations = {
321
334
  ...defaultChromeReservations(layout, zoom),
322
335
  };
@@ -440,51 +453,181 @@ function buildHeaderFooterRegion(
440
453
  zoom: RenderZoom,
441
454
  kind: "header" | "footer",
442
455
  storyTarget: EditorStoryTarget,
456
+ facet: WordReviewEditorLayoutFacet,
443
457
  ): RenderStoryRegion {
444
458
  const layout = page.layout;
445
- const widthTwips =
459
+ const fallbackWidthTwips =
446
460
  layout.pageWidth - layout.marginLeft - layout.marginRight;
447
- let topTwips = 0;
448
- let heightTwips = 0;
449
- if (kind === "header") {
450
- topTwips = layout.headerMargin ?? 720;
451
- heightTwips = Math.max(0, layout.marginTop - topTwips);
452
- } else {
453
- topTwips = layout.pageHeight - layout.marginBottom;
454
- heightTwips = Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
455
- }
461
+ const fallbackTopTwips =
462
+ kind === "header"
463
+ ? (layout.headerMargin ?? 720)
464
+ : layout.pageHeight - layout.marginBottom;
465
+ const fallbackHeightTwips =
466
+ kind === "header"
467
+ ? Math.max(0, layout.marginTop - (layout.headerMargin ?? 720))
468
+ : Math.max(0, layout.marginBottom - (layout.footerMargin ?? 720));
469
+
470
+ const region: PublicPageRegion =
471
+ (kind === "header" ? page.regions.header : page.regions.footer) ?? {
472
+ kind,
473
+ originTwips: fallbackTopTwips,
474
+ widthTwips: fallbackWidthTwips,
475
+ heightTwips: fallbackHeightTwips,
476
+ fragmentCount: 0,
477
+ };
456
478
 
457
479
  const frame: RenderFrameRect = {
458
480
  leftPx: layout.marginLeft * zoom.pxPerTwip,
459
- topPx: pageTopPx + topTwips * zoom.pxPerTwip,
460
- widthPx: widthTwips * zoom.pxPerTwip,
461
- heightPx: heightTwips * zoom.pxPerTwip,
481
+ topPx: pageTopPx + region.originTwips * zoom.pxPerTwip,
482
+ widthPx: region.widthTwips * zoom.pxPerTwip,
483
+ heightPx: region.heightTwips * zoom.pxPerTwip,
462
484
  };
463
485
 
464
- const region = kind === "header" ? page.regions.header : page.regions.footer;
465
- if (!region) {
466
- return {
467
- storyTarget,
468
- region: {
469
- kind,
470
- originTwips: topTwips,
471
- widthTwips,
472
- heightTwips,
473
- fragmentCount: 0,
474
- },
475
- frame,
476
- blocks: [],
477
- };
478
- }
486
+ const regionBlocks = facet.getStoryBlocksForRegion(page.pageIndex, kind);
487
+ const blocks = projectRegionBlocks(
488
+ regionBlocks,
489
+ frame,
490
+ zoom.pxPerTwip,
491
+ page.pageId,
492
+ page.pageIndex,
493
+ kind,
494
+ );
479
495
 
480
496
  return {
481
497
  storyTarget,
482
498
  region,
483
499
  frame,
484
- blocks: [],
500
+ blocks,
485
501
  };
486
502
  }
487
503
 
504
+ /**
505
+ * P8.3 — Build one `RenderStoryRegion` per `page.regions.footnotes` entry.
506
+ * The page graph reserves footnote regions at the bottom of the page
507
+ * (above the footer band) when `noteAllocations` produced fragments.
508
+ * Blocks come from `facet.getStoryBlocksForRegion(pageIndex, "footnote-area")`
509
+ * — one entry per allocated note body, stacked vertically.
510
+ *
511
+ * Currently the page graph emits a single footnote region per page
512
+ * covering every allocation, so the returned array has length 0 or 1. The
513
+ * shape allows for future allocation-splitting without changing the
514
+ * render-kernel contract.
515
+ */
516
+ function buildFootnoteRegions(
517
+ page: PublicPageNode,
518
+ pageTopPx: number,
519
+ zoom: RenderZoom,
520
+ facet: WordReviewEditorLayoutFacet,
521
+ ): RenderStoryRegion[] {
522
+ const footnoteRegions = page.regions.footnotes;
523
+ if (!footnoteRegions || footnoteRegions.length === 0) return [];
524
+
525
+ const regionBlocks = facet.getStoryBlocksForRegion(
526
+ page.pageIndex,
527
+ "footnote-area",
528
+ );
529
+ if (regionBlocks.length === 0) return [];
530
+
531
+ const results: RenderStoryRegion[] = [];
532
+ // Today the runtime emits a single footnote-area region per page; if that
533
+ // ever changes we will split `regionBlocks` across each region entry's
534
+ // `fragmentCount`. Preserve the shape so consumers can iterate safely.
535
+ let cursor = 0;
536
+ for (const region of footnoteRegions) {
537
+ const frame: RenderFrameRect = {
538
+ leftPx: page.layout.marginLeft * zoom.pxPerTwip,
539
+ topPx: pageTopPx + region.originTwips * zoom.pxPerTwip,
540
+ widthPx: region.widthTwips * zoom.pxPerTwip,
541
+ heightPx: region.heightTwips * zoom.pxPerTwip,
542
+ };
543
+
544
+ const blocksForThisRegion =
545
+ footnoteRegions.length === 1
546
+ ? regionBlocks
547
+ : regionBlocks.slice(cursor, cursor + region.fragmentCount);
548
+ cursor += region.fragmentCount;
549
+
550
+ const blocks = projectRegionBlocks(
551
+ blocksForThisRegion,
552
+ frame,
553
+ zoom.pxPerTwip,
554
+ page.pageId,
555
+ page.pageIndex,
556
+ "footnote-area",
557
+ );
558
+
559
+ // Footnote regions don't have a single canonical storyTarget (each
560
+ // block belongs to a different footnote body). Pick the first block's
561
+ // note as the region's primary story when available so chrome surfaces
562
+ // keyed on `storyTarget.kind === "footnote"` can dispatch on the band.
563
+ const firstNote = page.noteAllocations.find(
564
+ (alloc) => alloc.noteKind === "footnote",
565
+ );
566
+ const storyTarget: EditorStoryTarget = firstNote
567
+ ? { kind: "footnote", noteId: firstNote.noteId }
568
+ : MAIN_STORY_TARGET;
569
+
570
+ results.push({
571
+ storyTarget,
572
+ region,
573
+ frame,
574
+ blocks,
575
+ });
576
+ }
577
+
578
+ return results;
579
+ }
580
+
581
+ /**
582
+ * P8.3 — Stack a `PublicRegionBlock[]` into `RenderBlock[]` inside the
583
+ * given region frame. The cursor starts at `regionFrame.topPx` and
584
+ * advances by each block's `heightTwips × pxPerTwip`. Synthesizes a
585
+ * `PublicBlockFragment` shape for each block so chrome surfaces reading
586
+ * `RenderBlock.fragment` see a consistent shape across body / header /
587
+ * footer / footnote-area regions.
588
+ */
589
+ function projectRegionBlocks(
590
+ regionBlocks: readonly PublicRegionBlock[],
591
+ regionFrame: RenderFrameRect,
592
+ pxPerTwip: number,
593
+ pageId: string,
594
+ pageIndex: number,
595
+ regionKind: PublicPageRegion["kind"],
596
+ ): RenderBlock[] {
597
+ const blocks: RenderBlock[] = [];
598
+ let y = regionFrame.topPx;
599
+ for (let i = 0; i < regionBlocks.length; i += 1) {
600
+ const regionBlock = regionBlocks[i]!;
601
+ const blockHeightPx = Math.max(0, regionBlock.heightTwips) * pxPerTwip;
602
+ const blockFrame: RenderFrameRect = {
603
+ leftPx: regionFrame.leftPx,
604
+ topPx: y,
605
+ widthPx: regionFrame.widthPx,
606
+ heightPx: blockHeightPx,
607
+ };
608
+ const fragment: PublicBlockFragment = {
609
+ fragmentId: regionBlock.fragmentId,
610
+ blockId: regionBlock.blockId,
611
+ pageId,
612
+ pageIndex,
613
+ regionKind,
614
+ from: regionBlock.runtimeFromOffset,
615
+ to: regionBlock.runtimeToOffset,
616
+ heightTwips: regionBlock.heightTwips,
617
+ orderInRegion: i,
618
+ };
619
+ blocks.push({
620
+ fragment,
621
+ frame: blockFrame,
622
+ kind: classifyBlockKindFromId(regionBlock.blockId),
623
+ lines: [],
624
+ blockDecorations: [],
625
+ });
626
+ y += blockHeightPx;
627
+ }
628
+ return blocks;
629
+ }
630
+
488
631
  // classifyBlockKind moved to `./block-fragment-projection.ts` (P4).
489
632
 
490
633
  function buildAnchorIndex(
@@ -0,0 +1,120 @@
1
+ import {
2
+ signWorkflowPayloadXml,
3
+ type PayloadSignature,
4
+ type PayloadSigner,
5
+ } from "../io/ooxml/payload-signature.ts";
6
+
7
+ /**
8
+ * Central re-sign hook. Every payload mutation — negotiation action,
9
+ * presentation edit, participant upsert, external-custody attach, and
10
+ * (in the P17 parallel lane) metadata-persistence toggle — MUST route
11
+ * through this helper before writing back to the docx. This keeps the
12
+ * tamper-evident invariant un-bypassable: no feature can silently
13
+ * mutate `bw:workflowPayload` without re-signing.
14
+ *
15
+ * The helper is intentionally thin — it does two jobs:
16
+ * 1. Replace (or append, if absent) the root `<bw:signature …/>`
17
+ * element inside the provided payload XML with a new signature
18
+ * over the canonicalized (bw-canon/1) form of the payload minus
19
+ * the signature itself.
20
+ * 2. Return the rewritten XML so the caller can persist it.
21
+ *
22
+ * The canonicalizer already excludes `bw:signature` from its hashing
23
+ * surface; this helper reuses that guarantee instead of reimplementing
24
+ * it.
25
+ */
26
+ export interface ResignPayloadArgs {
27
+ /** The full `<bw:workflowPayload …>…</bw:workflowPayload>` XML. */
28
+ payloadXml: string;
29
+ signer: PayloadSigner;
30
+ /** Optional clock override for deterministic tests. */
31
+ now?: string;
32
+ }
33
+
34
+ export interface ResignPayloadResult {
35
+ payloadXml: string;
36
+ signature: PayloadSignature;
37
+ }
38
+
39
+ export async function resignPayload(
40
+ args: ResignPayloadArgs,
41
+ ): Promise<ResignPayloadResult> {
42
+ const stripped = removeExistingSignature(args.payloadXml);
43
+ const signature = await signWorkflowPayloadXml(stripped, args.signer, args.now);
44
+ const signatureElement = renderSignatureElement(signature);
45
+ const payloadXml = insertSignatureBeforeClose(stripped, signatureElement);
46
+ return { payloadXml, signature };
47
+ }
48
+
49
+ const SIGNATURE_OPEN = /<(?:[A-Za-z_][\w-]*:)?signature\b/;
50
+ const CLOSE_ROOT = /<\/(?:[A-Za-z_][\w-]*:)?workflowPayload\s*>\s*$/;
51
+
52
+ function removeExistingSignature(xml: string): string {
53
+ // The canonicalizer drops bw:signature before hashing, but the stored
54
+ // XML keeps it. For a round-trippable rewrite we strip every
55
+ // self-closing or block-form signature element at the top level. We
56
+ // do this with a tolerant regex instead of a full parse because the
57
+ // host may have normalized the XML (whitespace / attr order) between
58
+ // writes; we just need to locate the element's extent.
59
+ let out = xml;
60
+ while (true) {
61
+ const match = SIGNATURE_OPEN.exec(out);
62
+ if (!match) break;
63
+ const start = match.index;
64
+ const rest = out.slice(start);
65
+ // Self-closing form: `<…signature …/>`
66
+ const selfClose = rest.match(/^<[^>]*?\/>/);
67
+ if (selfClose) {
68
+ out = out.slice(0, start) + out.slice(start + selfClose[0].length);
69
+ continue;
70
+ }
71
+ // Block form: `<…signature …>…</…signature>`
72
+ const openEnd = rest.indexOf(">");
73
+ if (openEnd < 0) break;
74
+ const tagName = match[0].slice(1); // drops leading <
75
+ const closeTag = new RegExp(`</${escapeRegex(tagName)}\\s*>`);
76
+ const closeMatch = closeTag.exec(rest);
77
+ if (!closeMatch) break;
78
+ const end = closeMatch.index + closeMatch[0].length;
79
+ out = out.slice(0, start) + out.slice(start + end);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function insertSignatureBeforeClose(xml: string, signatureElement: string): string {
85
+ const match = CLOSE_ROOT.exec(xml);
86
+ if (!match) {
87
+ throw new Error(
88
+ "resignPayload: could not locate </bw:workflowPayload> close tag",
89
+ );
90
+ }
91
+ const insertAt = match.index;
92
+ return (
93
+ xml.slice(0, insertAt) +
94
+ signatureElement +
95
+ xml.slice(insertAt)
96
+ );
97
+ }
98
+
99
+ function renderSignatureElement(sig: PayloadSignature): string {
100
+ return (
101
+ `<bw:signature` +
102
+ ` algorithm="${escapeAttr(sig.algorithm)}"` +
103
+ ` keyId="${escapeAttr(sig.keyId)}"` +
104
+ ` signedAt="${escapeAttr(sig.signedAt)}"` +
105
+ ` canonicalizationProfile="${escapeAttr(sig.canonicalizationProfile)}"` +
106
+ ` value="${escapeAttr(sig.value)}"/>`
107
+ );
108
+ }
109
+
110
+ function escapeAttr(v: string): string {
111
+ return v
112
+ .replace(/&/g, "&amp;")
113
+ .replace(/</g, "&lt;")
114
+ .replace(/>/g, "&gt;")
115
+ .replace(/"/g, "&quot;");
116
+ }
117
+
118
+ function escapeRegex(s: string): string {
119
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
+ }
@@ -107,7 +107,7 @@ export function createEditorSurfaceSnapshot(
107
107
  }
108
108
  }
109
109
 
110
- const secondaryStories = createSecondaryStorySurfaces(document);
110
+ const secondaryStories = createSecondaryStorySurfaces(document, numberingPrefixResolver);
111
111
 
112
112
  return {
113
113
  storySize: cursor,
@@ -886,9 +886,13 @@ function appendInlineSegments(
886
886
  return { nextCursor: start + 1, lockedFragmentIds: [node.fragmentId] };
887
887
  }
888
888
  case "chart_preview":
889
- return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node));
889
+ return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
890
+ previewMediaId: node.previewMediaId,
891
+ });
890
892
  case "smartart_preview":
891
- return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node));
893
+ return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node), {
894
+ previewMediaId: node.previewMediaId,
895
+ });
892
896
  case "shape":
893
897
  if (promoteSecondaryStoryTextBoxes && node.isTextBox && node.text) {
894
898
  return appendTextBoxSegment(
@@ -1023,6 +1027,7 @@ function appendComplexPreviewSegment(
1023
1027
  start: number,
1024
1028
  label: string,
1025
1029
  detail: string,
1030
+ extras: { previewMediaId?: string } = {},
1026
1031
  ): { nextCursor: number; lockedFragmentIds: string[] } {
1027
1032
  paragraph.segments.push({
1028
1033
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -1033,6 +1038,7 @@ function appendComplexPreviewSegment(
1033
1038
  warningId: `warning:complex-preview:${start}`,
1034
1039
  label,
1035
1040
  detail,
1041
+ ...(extras.previewMediaId ? { previewMediaId: extras.previewMediaId } : {}),
1036
1042
  state: "locked-preserve-only",
1037
1043
  });
1038
1044
  return { nextCursor: start + 1, lockedFragmentIds: [] };
@@ -1174,6 +1180,7 @@ function createPlainText(
1174
1180
 
1175
1181
  function createSecondaryStorySurfaces(
1176
1182
  document: CanonicalDocumentEnvelope,
1183
+ numberingPrefixResolver: NumberingPrefixResolver,
1177
1184
  ): SecondaryStorySurface[] {
1178
1185
  const surfaces: SecondaryStorySurface[] = [];
1179
1186
  const subParts = document.subParts;
@@ -1181,8 +1188,6 @@ function createSecondaryStorySurfaces(
1181
1188
  return surfaces;
1182
1189
  }
1183
1190
 
1184
- const numberingPrefixResolver = createNumberingPrefixResolver(document.numbering);
1185
-
1186
1191
  for (const section of collectSectionContexts(document)) {
1187
1192
  const headerVariants = resolveSectionVariants(
1188
1193
  "header",
@@ -0,0 +1,157 @@
1
+ import {
2
+ verifyWorkflowPayloadXml,
3
+ type PayloadSignature,
4
+ type PayloadVerifier,
5
+ } from "../io/ooxml/payload-signature.ts";
6
+
7
+ /**
8
+ * Runtime metadata-integrity states.
9
+ *
10
+ * - `unsigned` — payload has no `bw:signature`, treated as trust-on-
11
+ * first-use. Callers may mutate freely; the first
12
+ * mutation will re-sign and transition to `verified`.
13
+ * - `verified` — signature present and valid against the registered
14
+ * verifier. Mutations are allowed through the gate.
15
+ * - `tampered` — signature present but verification failed. All
16
+ * mutations are blocked with `metadata_tampered` until
17
+ * the user calls `acknowledge()`.
18
+ */
19
+ export type MetadataIntegrity = "unsigned" | "verified" | "tampered";
20
+
21
+ export type TamperGateEvent =
22
+ | { type: "integrity_changed"; state: MetadataIntegrity }
23
+ | { type: "metadata_integrity_violation" };
24
+
25
+ export interface TamperGateArgs {
26
+ /**
27
+ * Verifier used on attach. Matches the signer the host writes the
28
+ * payload with. Omit to skip verification (payload is treated as
29
+ * unsigned).
30
+ */
31
+ verifier?: PayloadVerifier;
32
+ }
33
+
34
+ export interface AttachArgs {
35
+ payloadXml: string;
36
+ signature: PayloadSignature | undefined;
37
+ }
38
+
39
+ export type GuardResult =
40
+ | { ok: true }
41
+ | { ok: false; reason: "metadata_tampered" };
42
+
43
+ export interface TamperGate {
44
+ readonly state: MetadataIntegrity;
45
+ /**
46
+ * Verifies the signature against the supplied payload XML and
47
+ * transitions the gate state. Idempotent — safe to call every
48
+ * time a new payload is attached.
49
+ */
50
+ attach(args: AttachArgs): Promise<MetadataIntegrity>;
51
+ /**
52
+ * Resets the gate to `unsigned`. Useful when the underlying
53
+ * payload is detached (host switched documents).
54
+ */
55
+ detach(): void;
56
+ /**
57
+ * Single chokepoint every mutation path consults before writing
58
+ * back to the payload. Returns `{ ok: false, reason: "metadata_tampered" }`
59
+ * when the gate is in `tampered` state and the user has not
60
+ * acknowledged. Returns `{ ok: true }` otherwise.
61
+ */
62
+ guard(): GuardResult;
63
+ /**
64
+ * Flips `tampered` → `verified`. Only effective when the current
65
+ * state is `tampered`; other states are left untouched.
66
+ */
67
+ acknowledge(): void;
68
+ /**
69
+ * Re-enters `tampered` — e.g. when a host writes a payload and the
70
+ * re-sign check disagrees with the stored signature. Primarily for
71
+ * test fixtures and future composition slices.
72
+ */
73
+ flagTampered(): void;
74
+ subscribe(listener: (event: TamperGateEvent) => void): () => void;
75
+ destroy(): void;
76
+ }
77
+
78
+ export function createTamperGate(args: TamperGateArgs = {}): TamperGate {
79
+ let state: MetadataIntegrity = "unsigned";
80
+ let hasEmittedViolation = false;
81
+ const listeners = new Set<(event: TamperGateEvent) => void>();
82
+
83
+ const emit = (event: TamperGateEvent): void => {
84
+ for (const fn of [...listeners]) fn(event);
85
+ };
86
+
87
+ const setState = (next: MetadataIntegrity): void => {
88
+ if (next === state) return;
89
+ state = next;
90
+ emit({ type: "integrity_changed", state: next });
91
+ if (next === "tampered" && !hasEmittedViolation) {
92
+ hasEmittedViolation = true;
93
+ emit({ type: "metadata_integrity_violation" });
94
+ }
95
+ if (next !== "tampered") {
96
+ // Re-arm the violation emitter so a subsequent tamper transition
97
+ // re-notifies the host. Matches the "once per open on tamper"
98
+ // requirement, where "open" is bracketed by acknowledge().
99
+ hasEmittedViolation = false;
100
+ }
101
+ };
102
+
103
+ return {
104
+ get state() {
105
+ return state;
106
+ },
107
+
108
+ async attach({ payloadXml, signature }) {
109
+ if (!signature) {
110
+ setState("unsigned");
111
+ return state;
112
+ }
113
+ if (!args.verifier) {
114
+ setState("unsigned");
115
+ return state;
116
+ }
117
+ const ok = await verifyWorkflowPayloadXml(
118
+ payloadXml,
119
+ signature,
120
+ args.verifier,
121
+ );
122
+ setState(ok ? "verified" : "tampered");
123
+ return state;
124
+ },
125
+
126
+ detach() {
127
+ setState("unsigned");
128
+ },
129
+
130
+ guard() {
131
+ if (state === "tampered") {
132
+ return { ok: false, reason: "metadata_tampered" };
133
+ }
134
+ return { ok: true };
135
+ },
136
+
137
+ acknowledge() {
138
+ if (state !== "tampered") return;
139
+ setState("verified");
140
+ },
141
+
142
+ flagTampered() {
143
+ setState("tampered");
144
+ },
145
+
146
+ subscribe(listener) {
147
+ listeners.add(listener);
148
+ return () => {
149
+ listeners.delete(listener);
150
+ };
151
+ },
152
+
153
+ destroy() {
154
+ listeners.clear();
155
+ },
156
+ };
157
+ }