@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.
- package/package.json +38 -37
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +541 -5
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +601 -9
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +131 -1
- package/src/io/docx-session.ts +672 -2
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +367 -0
- package/src/io/ooxml/workflow-payload.ts +317 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +639 -124
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +139 -14
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +441 -48
- package/src/runtime/layout/public-facet.ts +585 -14
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +80 -16
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +654 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +111 -11
- package/src/ui/editor-shell-view.tsx +21 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +37 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- 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
|
|
459
|
+
const fallbackWidthTwips =
|
|
446
460
|
layout.pageWidth - layout.marginLeft - layout.marginRight;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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 +
|
|
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
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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, "&")
|
|
113
|
+
.replace(/</g, "<")
|
|
114
|
+
.replace(/>/g, ">")
|
|
115
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|