@beyondwork/docx-react-component 1.0.96 → 1.0.98

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +47 -19
  3. package/src/api/v3/ui/_types.ts +11 -21
  4. package/src/api/v3/ui/chrome.ts +8 -9
  5. package/src/api/v3/ui/debug.ts +15 -77
  6. package/src/api/v3/ui/overlays-visibility.ts +9 -10
  7. package/src/api/v3/ui/overlays.ts +8 -75
  8. package/src/io/ooxml/parse-main-document.ts +30 -0
  9. package/src/io/ooxml/parse-picture.ts +14 -0
  10. package/src/io/ooxml/parse-shapes.ts +41 -1
  11. package/src/io/ooxml/payload-signature.ts +101 -0
  12. package/src/model/canonical-document.ts +17 -0
  13. package/src/runtime/layout/layout-engine-version.ts +14 -1
  14. package/src/runtime/layout/page-story-resolver.ts +1 -0
  15. package/src/runtime/layout/paginated-layout-engine.ts +26 -10
  16. package/src/runtime/surface-projection.ts +114 -12
  17. package/src/session/export/stateful-export-pipeline.ts +18 -2
  18. package/src/session/export/stateful-export.ts +21 -1
  19. package/src/ui/WordReviewEditor.tsx +6 -10
  20. package/src/ui/editor-command-bag.ts +2 -0
  21. package/src/ui/ui-controller-factory.ts +2 -2
  22. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +11 -25
  23. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +2 -2
  24. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +1 -1
  25. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +22 -220
  26. package/src/ui-tailwind/debug/README.md +12 -50
  27. package/src/ui-tailwind/debug/tw-debug-overlay.tsx +6 -6
  28. package/src/ui-tailwind/debug/tw-debug-presentation.tsx +9 -20
  29. package/src/ui-tailwind/debug/tw-debug-top-bar.tsx +5 -6
  30. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +1 -4
  31. package/src/ui-tailwind/editor-surface/picture-effects.ts +96 -0
  32. package/src/ui-tailwind/editor-surface/pm-schema.ts +89 -62
  33. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +205 -0
  34. package/src/ui-tailwind/editor-surface/preserve-position.ts +12 -2
  35. package/src/ui-tailwind/editor-surface/runtime-decoration-plugin.ts +190 -0
  36. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +67 -56
  37. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +83 -20
  38. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +114 -4
  39. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +5 -0
  40. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +3 -0
  41. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +3 -0
  42. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  43. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +26 -0
  44. package/src/ui-tailwind/theme/editor-theme.css +18 -11
  45. package/src/ui-tailwind/tw-review-workspace.tsx +15 -0
@@ -10,7 +10,11 @@
10
10
  * preserved in the canonical node's rawXml field for lossless round-trip export.
11
11
  */
12
12
 
13
- import type { BlockNode, ShapeContent } from "../../model/canonical-document.ts";
13
+ import type {
14
+ BlockNode,
15
+ ShapeContent,
16
+ TextBoxBodyProperties,
17
+ } from "../../model/canonical-document.ts";
14
18
  import { parseFill } from "./parse-fill.ts";
15
19
  import {
16
20
  type XmlElementNode,
@@ -32,6 +36,8 @@ export interface ParsedWpsShape {
32
36
  text?: string;
33
37
  /** Raw txbxContent XML for structured re-rendering. */
34
38
  txbxContentXml?: string;
39
+ /** Text-box body layout from wps:bodyPr / a:bodyPr. */
40
+ textBoxBody?: TextBoxBodyProperties;
35
41
  /**
36
42
  * Parsed block-level structure from `w:txbxContent`, populated when a
37
43
  * `blockParser` callback is supplied (coord-02 §14 / coord-11 §22 —
@@ -97,6 +103,7 @@ export function parseShapeXml(
97
103
  const txbx = findFirstChild(wsp, "txbx");
98
104
  const txbxContent = txbx ? findFirstDescendant(txbx, "txbxContent") : null;
99
105
  const text = txbxContent ? extractAllText(txbxContent).trim() || undefined : undefined;
106
+ const textBoxBody = readTextBoxBody(wsp);
100
107
 
101
108
  // WordArt detection: geometry preset names that start with "text"
102
109
  if (prst && /^text/i.test(prst)) {
@@ -145,6 +152,7 @@ export function parseShapeXml(
145
152
  type: "shape",
146
153
  ...(isTextBox ? { isTextBox: true } : {}),
147
154
  ...(text ? { text } : {}),
155
+ ...(textBoxBody ? { textBoxBody } : {}),
148
156
  ...(txbxContentXml ? { txbxContentXml } : {}),
149
157
  ...(txbxBlocks && txbxBlocks.length > 0 ? { txbxBlocks } : {}),
150
158
  ...(prst ? { geometry: prst } : {}),
@@ -254,12 +262,14 @@ export function parseShapeContent(
254
262
 
255
263
  const fill = spPr ? readFill(spPr) : undefined;
256
264
  const line = spPr ? readLine(spPr) : undefined;
265
+ const textBoxBody = readTextBoxBody(wsp);
257
266
 
258
267
  // Text-box content — preserve raw XML for serialization + recurse via the
259
268
  // optional blockParser callback (CO4 F3.3) to populate txbxBlocks.
260
269
  const txbx = findFirstChild(wsp, "txbx");
261
270
  const txbxContent = txbx ? findFirstDescendant(txbx, "txbxContent") : undefined;
262
271
  const txbxContentXml = txbxContent ? extractRawXml(txbxContent) : undefined;
272
+ const text = txbxContent ? extractAllText(txbxContent).trim() || undefined : undefined;
263
273
 
264
274
  let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
265
275
  if (txbxContentXml && blockParser) {
@@ -286,14 +296,37 @@ export function parseShapeContent(
286
296
 
287
297
  const result: ShapeContent = { type: "shape", rawXml: drawingRawXml };
288
298
  if (geometry) result.geometry = geometry;
299
+ if (text) result.text = text;
289
300
  if (fill) result.fill = fill;
290
301
  if (line) result.line = line;
291
302
  if (isTextBox) result.isTextBox = true;
303
+ if (textBoxBody) result.textBoxBody = textBoxBody;
292
304
  if (txbxContentXml) result.txbxContentXml = txbxContentXml;
293
305
  if (txbxBlocks && txbxBlocks.length > 0) result.txbxBlocks = txbxBlocks;
294
306
  return result;
295
307
  }
296
308
 
309
+ function readTextBoxBody(wsp: XmlElementNode): TextBoxBodyProperties | undefined {
310
+ const bodyPr = findFirstChild(wsp, "bodyPr");
311
+ if (!bodyPr) return undefined;
312
+
313
+ const result: TextBoxBodyProperties = {};
314
+ const anchor = bodyPr.attributes.anchor;
315
+ if (anchor === "t" || anchor === "ctr" || anchor === "b") {
316
+ result.anchor = anchor;
317
+ }
318
+ const left = readIntAttr(bodyPr, "lIns");
319
+ const top = readIntAttr(bodyPr, "tIns");
320
+ const right = readIntAttr(bodyPr, "rIns");
321
+ const bottom = readIntAttr(bodyPr, "bIns");
322
+ if (left !== undefined) result.insetLeftEmu = left;
323
+ if (top !== undefined) result.insetTopEmu = top;
324
+ if (right !== undefined) result.insetRightEmu = right;
325
+ if (bottom !== undefined) result.insetBottomEmu = bottom;
326
+
327
+ return Object.keys(result).length > 0 ? result : undefined;
328
+ }
329
+
297
330
  // F3.4 + P5 — readFill delegates to the shared `parseFill` primitive covering
298
331
  // solid / none / gradient / pattern. Lane 5 chart-style cascade can consume
299
332
  // the same parser via `src/io/ooxml/parse-fill.ts`.
@@ -320,3 +353,10 @@ function readLine(
320
353
  }
321
354
  return Object.keys(result).length > 0 ? result : undefined;
322
355
  }
356
+
357
+ function readIntAttr(node: XmlElementNode, attr: string): number | undefined {
358
+ const raw = node.attributes[attr];
359
+ if (raw === undefined) return undefined;
360
+ const parsed = parseInt(raw, 10);
361
+ return Number.isFinite(parsed) ? parsed : undefined;
362
+ }
@@ -49,6 +49,107 @@ export async function verifyWorkflowPayloadXml(
49
49
  return verifier.verify(bytes, sig);
50
50
  }
51
51
 
52
+ /**
53
+ * Coord-06 cleanup §3 (2026-04-24) — sign the workflow payload XML and
54
+ * inject a `<bw:signature>` block into the payload root. The block is
55
+ * excluded from canonicalization before hashing (see
56
+ * `canonicalize-payload.ts::stripSignature`) so the signature is
57
+ * self-consistent: writer and reader both canonicalize-with-signature-
58
+ * stripped, so the same bytes hash.
59
+ *
60
+ * The injection happens inside the root `<bw:workflowPayload>` element,
61
+ * before the closing tag. Idempotent — re-signing on export re-injects
62
+ * a fresh signature block, stripping any prior one first.
63
+ */
64
+ export async function signAndInjectWorkflowPayloadSignature(
65
+ xml: string,
66
+ signer: PayloadSigner,
67
+ now: string = new Date().toISOString(),
68
+ ): Promise<string> {
69
+ const withoutPriorSignature = stripSignatureBlockFromPayload(xml);
70
+ const signature = await signWorkflowPayloadXml(withoutPriorSignature, signer, now);
71
+ return injectSignatureBlock(withoutPriorSignature, signature);
72
+ }
73
+
74
+ /**
75
+ * Parse a `<bw:signature ...>base64...</bw:signature>` block out of a
76
+ * workflow payload XML string. Returns `null` when the payload is
77
+ * unsigned (no `<bw:signature>` found) or when the block is malformed
78
+ * (missing required attributes / unparseable). Malformed returns null
79
+ * rather than throwing — the caller flips the tamper-gate to
80
+ * `"unsigned"` on null and to `"tampered"` only when verification
81
+ * fails on a well-formed signature block.
82
+ */
83
+ export function extractWorkflowPayloadSignature(
84
+ xml: string,
85
+ ): PayloadSignature | null {
86
+ // Match `<bw:signature ...>...</bw:signature>` or self-closing.
87
+ const re =
88
+ /<bw:signature\b([^>]*?)(?:\/>|>([\s\S]*?)<\/bw:signature>)/u;
89
+ const m = re.exec(xml);
90
+ if (!m) return null;
91
+ const attrs = parseAttributes(m[1] ?? "");
92
+ const body = (m[2] ?? "").trim();
93
+ const algorithm = attrs.algorithm;
94
+ const keyId = attrs.keyId;
95
+ const signedAt = attrs.signedAt;
96
+ const canonicalizationProfile = attrs.canonicalizationProfile;
97
+ if (!algorithm || !keyId || !signedAt || !canonicalizationProfile) return null;
98
+ if (algorithm !== "hmac-sha256" && algorithm !== "ed25519") return null;
99
+ if (canonicalizationProfile !== "bw-canon/1") return null;
100
+ if (!body) return null;
101
+ return {
102
+ algorithm,
103
+ keyId,
104
+ signedAt,
105
+ canonicalizationProfile,
106
+ value: body,
107
+ };
108
+ }
109
+
110
+ function stripSignatureBlockFromPayload(xml: string): string {
111
+ return xml.replace(
112
+ /<bw:signature\b[^>]*?(?:\/>|>[\s\S]*?<\/bw:signature>)\s*/u,
113
+ "",
114
+ );
115
+ }
116
+
117
+ function injectSignatureBlock(xml: string, sig: PayloadSignature): string {
118
+ const block =
119
+ `<bw:signature algorithm="${escapeAttr(sig.algorithm)}"` +
120
+ ` keyId="${escapeAttr(sig.keyId)}"` +
121
+ ` signedAt="${escapeAttr(sig.signedAt)}"` +
122
+ ` canonicalizationProfile="${escapeAttr(sig.canonicalizationProfile)}">` +
123
+ `${sig.value}</bw:signature>`;
124
+ // Inject just before the closing `</bw:workflowPayload>` tag so the
125
+ // block is a direct child of the root element. Falls back to a
126
+ // root-tag rewrite if the closing tag isn't the expected shape
127
+ // (e.g. the root is renamed in a future schema bump).
128
+ const closeRe = /<\/bw:workflowPayload>\s*$/u;
129
+ if (closeRe.test(xml)) {
130
+ return xml.replace(closeRe, `${block}</bw:workflowPayload>`);
131
+ }
132
+ return xml.replace(/<\/([^>]+)>\s*$/u, `${block}</$1>`);
133
+ }
134
+
135
+ function parseAttributes(source: string): Record<string, string> {
136
+ const out: Record<string, string> = {};
137
+ const re = /([a-zA-Z_][\w:.-]*)\s*=\s*"([^"]*)"/gu;
138
+ let m: RegExpExecArray | null;
139
+ while ((m = re.exec(source)) !== null) {
140
+ out[m[1]] = m[2];
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function escapeAttr(value: string): string {
146
+ return value
147
+ .replaceAll("&", "&amp;")
148
+ .replaceAll('"', "&quot;")
149
+ .replaceAll("<", "&lt;")
150
+ .replaceAll(">", "&gt;");
151
+ }
152
+
52
153
  // HMAC-SHA256 helpers ----------------------------------------------------
53
154
 
54
155
  /**
@@ -1876,6 +1876,8 @@ export interface ShapeNode {
1876
1876
  text?: string;
1877
1877
  geometry?: string;
1878
1878
  isTextBox?: boolean;
1879
+ /** Text box body layout from `wps:bodyPr` / `a:bodyPr`. */
1880
+ textBoxBody?: TextBoxBodyProperties;
1879
1881
  /** Raw `<w:txbxContent>` XML, preserved for serialization + round-trip. */
1880
1882
  txbxContentXml?: string;
1881
1883
  /**
@@ -2001,6 +2003,8 @@ export interface PictureContent {
2001
2003
  packagePartName?: string;
2002
2004
  /** MIME resolved from the OPC media part, when known. */
2003
2005
  contentType?: string;
2006
+ /** DrawingML a:lum brightness/contrast adjustments on the blip. Values are OOXML fixed percentages. */
2007
+ lum?: { bright?: number; contrast?: number };
2004
2008
  srcRect?: { top: number; bottom: number; left: number; right: number };
2005
2009
  stretch?: boolean;
2006
2010
  /**
@@ -2032,6 +2036,8 @@ export interface PictureContent {
2032
2036
 
2033
2037
  export interface ShapeContent {
2034
2038
  type: "shape";
2039
+ /** Plain-text fallback extracted from `w:txbxContent` when present. */
2040
+ text?: string;
2035
2041
  geometry?: string;
2036
2042
  /**
2037
2043
  * Shape fill — solid, gradient, pattern, or none. srgbClr values on solid +
@@ -2061,6 +2067,8 @@ export interface ShapeContent {
2061
2067
  line?: { color?: string; widthEmu?: number; noLine?: boolean };
2062
2068
  /** True when the shape's geometry + txbxContent presence make it a text box. */
2063
2069
  isTextBox?: boolean;
2070
+ /** Text box body layout from `wps:bodyPr` / `a:bodyPr`. */
2071
+ textBoxBody?: TextBoxBodyProperties;
2064
2072
  /** Raw w:txbxContent XML, preserved for serialization + lossless round-trip. */
2065
2073
  txbxContentXml?: string;
2066
2074
  /**
@@ -2080,6 +2088,15 @@ export interface ShapeContent {
2080
2088
  rawXml: string;
2081
2089
  }
2082
2090
 
2091
+ export interface TextBoxBodyProperties {
2092
+ /** OOXML bodyPr anchor: top, center, or bottom. */
2093
+ anchor?: "t" | "ctr" | "b";
2094
+ insetLeftEmu?: number;
2095
+ insetTopEmu?: number;
2096
+ insetRightEmu?: number;
2097
+ insetBottomEmu?: number;
2098
+ }
2099
+
2083
2100
  export interface DrawingFrameNode {
2084
2101
  type: "drawing_frame";
2085
2102
  anchor: AnchorGeometry;
@@ -1018,8 +1018,21 @@
1018
1018
  * of reconstructing chapter-prefixed numbering in caller-local formulas.
1019
1019
  * Cache envelopes from v59 invalidate because page graph line-box,
1020
1020
  * fragment geometry, and page-numbering payloads changed.
1021
+ *
1022
+ * 61 — Page-story resolver + paginator refresh landed on react-refactor
1023
+ * alongside the floating-drawings-in-page-view fix (`70acfd9ae`). Both
1024
+ * `page-story-resolver.ts` and `paginated-layout-engine.ts` changed
1025
+ * without an upstream bump. Bump here so persisted cache envelopes
1026
+ * re-derive; no algorithm change beyond the floating-drawing clip
1027
+ * widening already covered by the resurrected tests.
1028
+ *
1029
+ * 62 — nested break detection inside wrapped surface blocks. Page and
1030
+ * column break detection now walks `sdt_block` children and table-cell
1031
+ * content instead of checking only top-level paragraphs. This honors
1032
+ * `<w:br w:type="page"/>` inside cover-page SDTs such as the CCEP SOW
1033
+ * template, changing page assignment from the v61 cache shape.
1021
1034
  */
1022
- export const LAYOUT_ENGINE_VERSION = 60 as const;
1035
+ export const LAYOUT_ENGINE_VERSION = 62 as const;
1023
1036
 
1024
1037
  /**
1025
1038
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -149,6 +149,7 @@ function resolveStoryVariant(
149
149
  sectionIndex: layout.sectionIndex,
150
150
  };
151
151
  }
152
+ return undefined;
152
153
  }
153
154
 
154
155
  // Even page variant when evenAndOddHeaders is active
@@ -2432,17 +2432,33 @@ function isOutOfFlowFrame(
2432
2432
  }
2433
2433
 
2434
2434
  function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
2435
- return block.kind === "paragraph" && block.segments.some(
2436
- (segment) =>
2437
- segment.kind === "opaque_inline" &&
2438
- segment.label === "Column break",
2439
- );
2435
+ if (block.kind === "paragraph") {
2436
+ return block.segments.some(
2437
+ (segment) =>
2438
+ segment.kind === "opaque_inline" &&
2439
+ segment.label === "Column break",
2440
+ );
2441
+ }
2442
+ return nestedBlocks(block).some(hasColumnBreak);
2440
2443
  }
2441
2444
 
2442
2445
  function hasPageBreak(block: SurfaceBlockSnapshot): boolean {
2443
- return block.kind === "paragraph" && block.segments.some(
2444
- (segment) =>
2445
- segment.kind === "opaque_inline" &&
2446
- segment.label === "Page break",
2447
- );
2446
+ if (block.kind === "paragraph") {
2447
+ return block.segments.some(
2448
+ (segment) =>
2449
+ segment.kind === "opaque_inline" &&
2450
+ segment.label === "Page break",
2451
+ );
2452
+ }
2453
+ return nestedBlocks(block).some(hasPageBreak);
2454
+ }
2455
+
2456
+ function nestedBlocks(block: SurfaceBlockSnapshot): readonly SurfaceBlockSnapshot[] {
2457
+ if (block.kind === "sdt_block") {
2458
+ return block.children;
2459
+ }
2460
+ if (block.kind === "table") {
2461
+ return block.rows.flatMap((row) => row.cells.flatMap((cell) => cell.content));
2462
+ }
2463
+ return [];
2448
2464
  }
@@ -49,6 +49,34 @@ import {
49
49
  isBlockedImportFeatureKey,
50
50
  } from "../preservation/store.ts";
51
51
  import { getStoryBlocks } from "./story-targeting.ts";
52
+
53
+ /**
54
+ * Refactor/11b Slice A — internal seam for the L1 PM Node identity cache.
55
+ *
56
+ * Surface-projection attaches a parallel `(BlockNode | null)[]` array
57
+ * alongside `EditorSurfaceSnapshot.blocks` via a globally-registered
58
+ * Symbol. `pm-state-from-snapshot` reads it through an inline mirror of
59
+ * the same Symbol — `Symbol.for` guarantees both sides resolve to the
60
+ * same symbol even though they live in different layers and never
61
+ * import each other. This avoids a cross-layer import that would blow
62
+ * the Layer 11 boundary register.
63
+ *
64
+ * The property is non-enumerable, so JSON.stringify and public
65
+ * consumers of `EditorSurfaceSnapshot` see no change in shape.
66
+ */
67
+ const CANONICAL_BLOCK_REFS_SYMBOL = Symbol.for("wre.canonical-block-refs");
68
+
69
+ function attachCanonicalBlockRefs(
70
+ snapshot: EditorSurfaceSnapshot,
71
+ refs: readonly (BlockNode | null)[],
72
+ ): void {
73
+ Object.defineProperty(snapshot, CANONICAL_BLOCK_REFS_SYMBOL, {
74
+ value: refs,
75
+ enumerable: false,
76
+ configurable: true,
77
+ writable: false,
78
+ });
79
+ }
52
80
  import {
53
81
  collectSectionContexts,
54
82
  findHeaderFooterDocumentEntry,
@@ -182,6 +210,12 @@ export function createEditorSurfaceSnapshot(
182
210
  children: [...getStoryBlocks(document, activeStory)],
183
211
  });
184
212
  const blocks: SurfaceBlockSnapshot[] = [];
213
+ // Refactor/11b Slice A — canonical BlockNode ref parallel to `blocks`.
214
+ // Feeds the L1 identity cache in `pm-state-from-snapshot` so unchanged
215
+ // canonical blocks reuse their PM Node instance across commits, which
216
+ // lets PM's `node.eq()` short-circuit `ViewDesc.update()`. `null` for
217
+ // placeholder-culled blocks — those are synthetic, not canonical.
218
+ const blockRefs: (BlockNode | null)[] = [];
185
219
  const lockedFragmentIds: string[] = [];
186
220
  // L03 boundary: construct one formatting context per projection pass.
187
221
  // The context owns the theme resolver + numbering prefix counter +
@@ -251,6 +285,7 @@ export function createEditorSurfaceSnapshot(
251
285
 
252
286
  if (isInViewport) {
253
287
  blocks.push(surfaceBlock.block);
288
+ blockRefs.push(root.children[index]);
254
289
  lockedFragmentIds.push(...surfaceBlock.lockedFragmentIds);
255
290
  } else {
256
291
  // Replace with size-preserving placeholder. from/to track the SAME
@@ -270,6 +305,8 @@ export function createEditorSurfaceSnapshot(
270
305
  placeholderSize,
271
306
  state: "placeholder-culled",
272
307
  } as SurfaceBlockSnapshot);
308
+ // Placeholder has no canonical ref — L1 cache will skip it.
309
+ blockRefs.push(null);
273
310
  // Do NOT push lockedFragmentIds — placeholder has no real fragment.
274
311
  }
275
312
 
@@ -317,7 +354,7 @@ export function createEditorSurfaceSnapshot(
317
354
  }
318
355
  }
319
356
 
320
- return {
357
+ const snapshot: EditorSurfaceSnapshot = {
321
358
  storySize: cursor,
322
359
  plainText: createPlainText(blocks),
323
360
  blocks,
@@ -325,6 +362,8 @@ export function createEditorSurfaceSnapshot(
325
362
  secondaryStories,
326
363
  viewportBlockRanges,
327
364
  };
365
+ attachCanonicalBlockRefs(snapshot, blockRefs);
366
+ return snapshot;
328
367
  }
329
368
 
330
369
  /**
@@ -1427,7 +1466,7 @@ function appendInlineSegments(
1427
1466
  return { nextCursor: start + 1, lockedFragmentIds: [node.fragmentId] };
1428
1467
  }
1429
1468
  case "chart_preview": {
1430
- const parsedChartId = registerParsedChartPreview(node);
1469
+ const parsedChartId = registerParsedChartPreview(node, document);
1431
1470
  return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
1432
1471
  previewMediaId: node.previewMediaId,
1433
1472
  parsedChartId,
@@ -1438,6 +1477,9 @@ function appendInlineSegments(
1438
1477
  previewMediaId: node.previewMediaId,
1439
1478
  });
1440
1479
  case "shape":
1480
+ if (isMicrosoftSensitivityLabelShape(node)) {
1481
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1482
+ }
1441
1483
  if (promoteSecondaryStoryTextBoxes && node.isTextBox && node.text) {
1442
1484
  return appendTextBoxSegment(
1443
1485
  paragraph,
@@ -1452,6 +1494,9 @@ function appendInlineSegments(
1452
1494
  case "wordart":
1453
1495
  return appendComplexPreviewSegment(paragraph, node, start, "WordArt", createWordArtDetail(node));
1454
1496
  case "vml_shape":
1497
+ if (isMicrosoftSensitivityLabelShape(node)) {
1498
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1499
+ }
1455
1500
  if (promoteSecondaryStoryTextBoxes && shouldRenderSecondaryStoryVmlTextBox(node)) {
1456
1501
  return appendTextBoxSegment(
1457
1502
  paragraph,
@@ -1487,7 +1532,8 @@ function appendInlineSegments(
1487
1532
  const label = c.isTextBox ? "Text box" : "Drawing shape";
1488
1533
  const detail = `DrawingFrame shape (${node.anchor.wrapMode}).`;
1489
1534
  const anchor = surfaceAnchorFromGeometry(node.anchor);
1490
- const txbxText = c.isTextBox ? extractTxbxFirstText(c.txbxBlocks) : undefined;
1535
+ const txbxTextSegment = c.isTextBox ? extractTxbxFirstTextSegment(c.txbxBlocks) : undefined;
1536
+ const txbxText = txbxTextSegment?.text ?? (c.isTextBox ? c.text : undefined);
1491
1537
  const surfaceFill = c.fill;
1492
1538
  paragraph.segments.push({
1493
1539
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -1501,12 +1547,17 @@ function appendInlineSegments(
1501
1547
  ...(surfaceFill ? { fill: surfaceFill } : {}),
1502
1548
  ...(c.line ? { line: c.line } : {}),
1503
1549
  ...(c.isTextBox ? { isTextBox: true } : {}),
1550
+ ...(c.textBoxBody ? { textBoxBody: c.textBoxBody } : {}),
1504
1551
  ...(txbxText ? { txbxText } : {}),
1552
+ ...(txbxTextSegment?.marks && txbxTextSegment.marks.length > 0
1553
+ ? { txbxMarks: txbxTextSegment.marks }
1554
+ : {}),
1555
+ ...(txbxTextSegment?.markAttrs ? { txbxMarkAttrs: txbxTextSegment.markAttrs } : {}),
1505
1556
  });
1506
1557
  return { nextCursor: start + 1, lockedFragmentIds: [] };
1507
1558
  }
1508
1559
  if (c.type === "chart_preview") {
1509
- const parsedChartId = registerParsedChartPreview(c);
1560
+ const parsedChartId = registerParsedChartPreview(c, document);
1510
1561
  return appendComplexPreviewSegment(
1511
1562
  paragraph,
1512
1563
  c,
@@ -1752,7 +1803,10 @@ function appendInlineSegments(
1752
1803
  }
1753
1804
  }
1754
1805
 
1755
- function registerParsedChartPreview(node: ChartPreviewNode): string | undefined {
1806
+ function registerParsedChartPreview(
1807
+ node: ChartPreviewNode,
1808
+ document: CanonicalDocumentEnvelope,
1809
+ ): string | undefined {
1756
1810
  if (!node.parsedData) return undefined;
1757
1811
  const parsedChartId = stableChartId(node.rawXml);
1758
1812
  // Always call `set` (even when the entry exists) so the active
@@ -1763,7 +1817,7 @@ function registerParsedChartPreview(node: ChartPreviewNode): string | undefined
1763
1817
  model: node.parsedData,
1764
1818
  widthPx,
1765
1819
  heightPx,
1766
- theme: undefined,
1820
+ theme: document.subParts?.resolvedTheme,
1767
1821
  });
1768
1822
  return parsedChartId;
1769
1823
  }
@@ -1898,6 +1952,7 @@ function surfacePictureEffectsFromContent(
1898
1952
  const outerShadow = resolveSurfacePictureShadow(content.outerShadow, themeResolver);
1899
1953
  const glow = resolveSurfacePictureGlow(content.glow, themeResolver);
1900
1954
  const has =
1955
+ content.lum !== undefined ||
1901
1956
  content.srcRect !== undefined ||
1902
1957
  content.rotation !== undefined ||
1903
1958
  content.flipH !== undefined ||
@@ -1909,6 +1964,7 @@ function surfacePictureEffectsFromContent(
1909
1964
  glow !== undefined;
1910
1965
  if (!has) return undefined;
1911
1966
  return {
1967
+ ...(content.lum ? { lum: { ...content.lum } } : {}),
1912
1968
  ...(content.srcRect ? { srcRect: { ...content.srcRect } } : {}),
1913
1969
  ...(content.rotation !== undefined ? { rotation: content.rotation } : {}),
1914
1970
  ...(content.flipH !== undefined ? { flipH: content.flipH } : {}),
@@ -2007,16 +2063,51 @@ function flattenSurfaceFieldDisplayText(
2007
2063
  * and read the immediate run content. Returns `undefined` when no text
2008
2064
  * is present.
2009
2065
  */
2010
- function extractTxbxFirstText(
2066
+ function extractTxbxFirstTextSegment(
2011
2067
  blocks: ShapeContent["txbxBlocks"],
2012
- ): string | undefined {
2068
+ ): {
2069
+ text: string;
2070
+ marks?: SurfaceTextMark[];
2071
+ markAttrs?: {
2072
+ backgroundColor?: string;
2073
+ charSpacing?: number;
2074
+ kerning?: number;
2075
+ textFill?: string;
2076
+ fontFamily?: string;
2077
+ fontSize?: number;
2078
+ textColor?: string;
2079
+ };
2080
+ } | undefined {
2013
2081
  if (!blocks || blocks.length === 0) return undefined;
2014
2082
  for (const block of blocks) {
2015
2083
  if (block.type !== "paragraph") continue;
2016
- const runs = (block as { runs?: ReadonlyArray<{ text?: string }> }).runs;
2017
- if (!runs) continue;
2018
- const text = runs.map((r) => r.text ?? "").join("").trim();
2019
- if (text) return text;
2084
+ const children = (block as { children?: ReadonlyArray<{ type?: string; text?: string; marks?: TextMark[] }> }).children;
2085
+ const legacyRuns = (block as { runs?: ReadonlyArray<{ text?: string; marks?: TextMark[] }> }).runs;
2086
+ if (!children && !legacyRuns) continue;
2087
+ let firstMarks: TextMark[] | undefined;
2088
+ const text = (children
2089
+ ? children
2090
+ .map((child) => {
2091
+ if (child.type !== "text") return "";
2092
+ if (!firstMarks && child.marks) firstMarks = child.marks;
2093
+ return child.text ?? "";
2094
+ })
2095
+ .join("")
2096
+ : legacyRuns
2097
+ ?.map((run) => {
2098
+ if (!firstMarks && run.marks) firstMarks = run.marks;
2099
+ return run.text ?? "";
2100
+ })
2101
+ .join("") ?? ""
2102
+ ).trim();
2103
+ if (text) {
2104
+ const cloned = firstMarks ? cloneMarks(firstMarks) : undefined;
2105
+ return {
2106
+ text,
2107
+ ...(cloned?.marks && cloned.marks.length > 0 ? { marks: cloned.marks } : {}),
2108
+ ...(cloned?.markAttrs ? { markAttrs: cloned.markAttrs } : {}),
2109
+ };
2110
+ }
2020
2111
  }
2021
2112
  return undefined;
2022
2113
  }
@@ -2072,6 +2163,17 @@ function shouldRenderSecondaryStoryVmlTextBox(node: VmlShapeNode): boolean {
2072
2163
  return Boolean(node.text) && (!node.shapeType || /_x0000_t202$/iu.test(node.shapeType));
2073
2164
  }
2074
2165
 
2166
+ function isMicrosoftSensitivityLabelShape(
2167
+ node: ShapeNode | VmlShapeNode,
2168
+ ): boolean {
2169
+ if (!/classification/i.test(node.text ?? "")) {
2170
+ return false;
2171
+ }
2172
+ return /\b(?:id|name)="MSIPCM/iu.test(node.rawXml) ||
2173
+ /\b(?:alt|descr)="[^"]*&quot;Placement&quot;:&quot;Footer&quot;/iu.test(node.rawXml) ||
2174
+ /"Placement"\s*:\s*"Footer"/iu.test(node.rawXml);
2175
+ }
2176
+
2075
2177
  function createChartDetail(node: ChartPreviewNode): string {
2076
2178
  const parts = ["Embedded chart."];
2077
2179
  if (node.previewMediaId) {
@@ -164,6 +164,17 @@ export function ensureHostMetadataParts(
164
164
  });
165
165
  }
166
166
 
167
+ /**
168
+ * Return shape reports whether a payload part was written AND exposes
169
+ * the built XML + path so the export pipeline can post-process (e.g.
170
+ * coord-06 §3 sign-and-inject `<bw:signature>`). Returns `null` when
171
+ * no payload part was written (no runtime-owned content).
172
+ */
173
+ export interface EnsureWorkflowPayloadPartsResult {
174
+ readonly payloadPartPath: string;
175
+ readonly payloadPartXml: string;
176
+ }
177
+
167
178
  export function ensureWorkflowPayloadParts(
168
179
  exportSession: ExportSession,
169
180
  sessionState: EditorSessionState,
@@ -174,7 +185,7 @@ export function ensureWorkflowPayloadParts(
174
185
  itemPropsPartPath: string;
175
186
  },
176
187
  editorState?: EditorStatePayload,
177
- ): void {
188
+ ): EnsureWorkflowPayloadPartsResult | null {
178
189
  const payloadParts = buildWorkflowPayloadParts({
179
190
  sourcePackage,
180
191
  workflowMetadata: sessionState.workflowMetadata,
@@ -188,7 +199,7 @@ export function ensureWorkflowPayloadParts(
188
199
  producerVersion: sessionState.editorBuild,
189
200
  });
190
201
  if (!payloadParts) {
191
- return;
202
+ return null;
192
203
  }
193
204
  if (
194
205
  payloadParts.payloadPartPath !== resolvedPartPaths.payloadPartPath ||
@@ -228,6 +239,11 @@ export function ensureWorkflowPayloadParts(
228
239
  target: WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
229
240
  preferredId: "rIdBwWorkflowCustomProps",
230
241
  });
242
+
243
+ return {
244
+ payloadPartPath: payloadParts.payloadPartPath,
245
+ payloadPartXml: payloadParts.payloadPartXml,
246
+ };
231
247
  }
232
248
 
233
249
  export function hasHostSafeMetadataPackageStructure(sourcePackage: OpcPackage): boolean {
@@ -93,6 +93,7 @@ import {
93
93
  ensureWorkflowPayloadParts,
94
94
  serializeProtectionRangesIntoDocumentXml,
95
95
  } from "./stateful-export-pipeline.ts";
96
+ import { signAndInjectWorkflowPayloadSignature } from "../../io/ooxml/payload-signature.ts";
96
97
  import {
97
98
  assertExportNotBlockedByCompatibility,
98
99
  assertNoBlockingPreservedComments,
@@ -612,7 +613,7 @@ export async function runStatefulExport(
612
613
 
613
614
  // Schema 1.2: pass through editorState payload collected by the
614
615
  // runtime channel, with any offload entries folded in above.
615
- ensureWorkflowPayloadParts(
616
+ const payloadResult = ensureWorkflowPayloadParts(
616
617
  exportSession,
617
618
  sessionState,
618
619
  currentDocument,
@@ -621,6 +622,25 @@ export async function runStatefulExport(
621
622
  editorStateWithEmbeddings,
622
623
  );
623
624
 
625
+ // coord-06 cleanup §3 — if the host supplied a PayloadSigner, sign
626
+ // the built payload XML and rewrite the customXml/item1.xml part with
627
+ // a `<bw:signature>` block injected inside the root element. The
628
+ // canonicalization pass strips the block before hashing so signer
629
+ // and verifier agree; see `src/io/ooxml/canonicalize-payload.ts`.
630
+ if (options?.signer && payloadResult) {
631
+ const signedXml = await signAndInjectWorkflowPayloadSignature(
632
+ payloadResult.payloadPartXml,
633
+ options.signer,
634
+ );
635
+ const payloadPart = state.sourcePackage.parts.get(payloadResult.payloadPartPath);
636
+ exportSession.replaceOwnedPart({
637
+ path: payloadResult.payloadPartPath,
638
+ bytes: new TextEncoder().encode(signedXml),
639
+ contentType: payloadPart?.contentType ?? "application/xml",
640
+ compression: payloadPart?.compression,
641
+ });
642
+ }
643
+
624
644
  return {
625
645
  bytes: exportSession.serialize(),
626
646
  mimeType: DOCX_MIME_TYPE,