@beyondwork/docx-react-component 1.0.47 → 1.0.49

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 (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -44,8 +44,9 @@ import {
44
44
  normalizeParsedTextDocument,
45
45
  normalizeParsedTextDocumentAsync,
46
46
  } from "./normalize/normalize-text.ts";
47
- import { resolveChartPreviewsForDocument } from "./chart-preview-resolver.ts";
47
+ import { createChartPartLookup, resolveChartPreviewsForDocument } from "./chart-preview-resolver.ts";
48
48
  import { type LoadScheduler } from "./load-scheduler.ts";
49
+ import type { CacheEnvelope } from "../runtime/prerender/cache-envelope.ts";
49
50
  import {
50
51
  CONTENT_TYPES_PATH,
51
52
  PACKAGE_RELATIONSHIPS_PATH,
@@ -59,12 +60,14 @@ import {
59
60
  getDocumentBackedWorkflowMetadata,
60
61
  parseWorkflowPayloadEnvelopeFromPackage,
61
62
  resolvePayloadPartPath,
62
- resolveWorkflowPayloadPartPaths,
63
63
  WORKFLOW_PAYLOAD_CONTENT_TYPE,
64
64
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE,
65
65
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
66
66
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE,
67
67
  WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
68
+ WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
69
+ WORKFLOW_PAYLOAD_PART_PATH,
70
+ WORKFLOW_PAYLOAD_RELATIONSHIP_TYPE,
68
71
  } from "./ooxml/workflow-payload.ts";
69
72
  import {
70
73
  classifyCorruptPackageError,
@@ -74,6 +77,10 @@ import {
74
77
  import { buildAppPropertiesXml } from "./export/build-app-properties-xml.ts";
75
78
  import { createExportSession } from "./export/export-session.ts";
76
79
  import { serializeMainDocument } from "./export/serialize-main-document.ts";
80
+ import {
81
+ serializeSettingsXml,
82
+ WORD_SETTINGS_CONTENT_TYPE,
83
+ } from "./export/serialize-settings.ts";
77
84
  import {
78
85
  parseRevisionsFromDocumentXml,
79
86
  parseRevisionsFromStoryXml,
@@ -262,6 +269,21 @@ interface ImportedDocxState {
262
269
  sourceDocumentAttributes: Record<string, string>;
263
270
  sourceNumberingPartPath?: string;
264
271
  sourceNumberingRelationshipId?: string;
272
+ /**
273
+ * Resolved `/word/settings.xml` part path when the source package carried
274
+ * one. Threaded through to the export path so the settings serializer can
275
+ * call `replaceOwnedPart` with the right relationship target.
276
+ */
277
+ sourceSettingsPartPath?: string;
278
+ /**
279
+ * Original settings.xml bytes decoded as UTF-8. Passed to
280
+ * `serializeSettingsXml(settings, sourceXml)` as the graft source so
281
+ * unmodelled top-level children (`<w:defaultTabStop>`,
282
+ * `<w:documentProtection>`, mail-merge state, etc.) survive verbatim
283
+ * through round-trip. Undefined when the source package lacked a
284
+ * settings part.
285
+ */
286
+ sourceSettingsXml?: string;
265
287
  sourceCommentsPartPath?: string;
266
288
  sourceCommentsRelationshipId?: string;
267
289
  sourceCommentsRootTag?: string;
@@ -426,11 +448,17 @@ export function loadDocxEditorSession(
426
448
  )
427
449
  : createEmptyNumberingCatalog();
428
450
  const mediaParts = collectInlineMediaParts(sourcePackage);
451
+ const chartPartLookup = createChartPartLookup(
452
+ sourcePackage,
453
+ mainDocumentPath,
454
+ documentPart.relationships,
455
+ );
429
456
  const parsedDocument = parseMainDocumentXml(
430
457
  sourceDocumentXml,
431
458
  documentPart.relationships,
432
459
  mediaParts,
433
460
  mainDocumentPath,
461
+ chartPartLookup,
434
462
  );
435
463
  const protectionRanges = extractProtectionRanges(parsedDocument.blocks);
436
464
  const normalizedDocument = normalizeParsedTextDocument(
@@ -829,6 +857,9 @@ export function loadDocxEditorSession(
829
857
  relationship.type === NUMBERING_RELATIONSHIP_TYPE &&
830
858
  relationship.targetMode === "internal",
831
859
  )?.id,
860
+ sourceSettingsPartPath: settingsPartPath,
861
+ sourceSettingsXml:
862
+ settingsXmlForProtection.length > 0 ? settingsXmlForProtection : undefined,
832
863
  sourceCommentsPartPath: commentsPartPath,
833
864
  sourceCommentsRelationshipId: documentPart.relationships.find(
834
865
  (relationship) =>
@@ -896,7 +927,7 @@ export function loadDocxEditorSession(
896
927
  }
897
928
  }
898
929
 
899
- interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSessionOptions {
930
+ export interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSessionOptions {
900
931
  /**
901
932
  * Scheduler that the async loader awaits between parse stages. Callers
902
933
  * in DOM environments should construct this with `createLoadScheduler()`
@@ -907,6 +938,27 @@ interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSessionOptions
907
938
  * behaves like the sync path from the test harness POV.
908
939
  */
909
940
  scheduler: LoadScheduler;
941
+ /**
942
+ * L7 Phase 2.5 Plan B B.6b — optional laycache envelope. When supplied,
943
+ * `loadDocxEditorSessionAsync` still performs the cheap OPC read +
944
+ * workflow-payload parse (needed for `initialEditorStatePayload`,
945
+ * `workflowOverlay`, and `workflowMetadata`), then skips the five
946
+ * expensive stages — `parseMainDocumentXml`,
947
+ * `normalizeParsedTextDocumentAsync`, `parseCommentsFromOoxml`,
948
+ * `parseStylesXml`, and `createImportedCanonicalDocument` — and uses
949
+ * `envelope.canonicalDocument` directly (reference-equal, no clone).
950
+ *
951
+ * Callers obtain a validated envelope by calling
952
+ * `tryReadLaycacheEnvelope(bytes)` before invoking this function; when
953
+ * the probe returns `null`, omit this field and the loader runs the
954
+ * full parse.
955
+ *
956
+ * Async-only — the sync `loadDocxEditorSession` does not honor this
957
+ * option. `buildCompatibilityReport` and
958
+ * `resolveChartPreviewsForDocument` still run on the short-circuit
959
+ * path because their outputs are required by downstream consumers.
960
+ */
961
+ laycacheEnvelope?: CacheEnvelope;
910
962
  }
911
963
 
912
964
  /**
@@ -1006,6 +1058,137 @@ export async function loadDocxEditorSessionAsync(
1006
1058
  }
1007
1059
 
1008
1060
  try {
1061
+ // L7 Phase 2.5 Plan B B.6b — loader short-circuit. Hand
1062
+ // `envelope.canonicalDocument` through reference-equal and skip the
1063
+ // five expensive parse stages. The four `onLoadStage` callbacks still
1064
+ // fire in order — `body` and `styles-numbering-comments` emit with
1065
+ // near-zero duration — so host progress bars are unaffected.
1066
+ if (options.laycacheEnvelope) {
1067
+ stages.emit("body");
1068
+ stages.emit("styles-numbering-comments");
1069
+
1070
+ const canonicalDocument = options.laycacheEnvelope.canonicalDocument;
1071
+
1072
+ // `extractProtectionRanges` needs `parsedDocument.blocks` (which we
1073
+ // are skipping), so the short-circuit uses an empty ranges list;
1074
+ // document-level `editType` / `enforcement` still come from
1075
+ // settings.xml so read-only docs stay read-only.
1076
+ const settingsPartPath = resolveDocumentRelatedPartPath(
1077
+ sourcePackage,
1078
+ mainDocumentPath,
1079
+ documentPart.relationships,
1080
+ SETTINGS_RELATIONSHIP_TYPE,
1081
+ SETTINGS_PART_PATH,
1082
+ );
1083
+ const settingsXml =
1084
+ settingsPartPath && sourcePackage.parts.has(settingsPartPath)
1085
+ ? decodeUtf8(
1086
+ sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array(),
1087
+ )
1088
+ : "";
1089
+ const documentProtection = extractDocumentProtection(settingsXml);
1090
+ const protectionSnapshot = buildProtectionSnapshot(documentProtection, []);
1091
+
1092
+ // Chart previews (`previewMediaId` is host-dependent) aren't cached
1093
+ // in the envelope, so we still resolve them on the short-circuit.
1094
+ const documentWithChartPreviews = (await resolveChartPreviewsForDocument(
1095
+ canonicalDocument,
1096
+ sourcePackage,
1097
+ options.hostAdapter,
1098
+ )) as CanonicalDocumentEnvelope;
1099
+
1100
+ const timestamp = new Date().toISOString();
1101
+ const compatibility = buildCompatibilityReport({
1102
+ document: documentWithChartPreviews,
1103
+ generatedAt: timestamp,
1104
+ });
1105
+ await scheduler.yield();
1106
+
1107
+ const snapshot = createImportedSnapshot({
1108
+ documentId: options.documentId,
1109
+ editorBuild,
1110
+ timestamp,
1111
+ document: documentWithChartPreviews,
1112
+ compatibility: toPublicCompatibilityReport(compatibility),
1113
+ protectionSnapshot,
1114
+ sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
1115
+ workflowOverlay: embeddedWorkflowOverlay,
1116
+ workflowMetadata: embeddedWorkflowMetadata,
1117
+ });
1118
+ const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
1119
+ if (snapshotIssues.length > 0) {
1120
+ const firstIssue = snapshotIssues[0];
1121
+ return createDiagnosticsSession(
1122
+ options,
1123
+ createValidationImportDiagnostics({
1124
+ message: `DOCX import produced an invalid editor state during validation${firstIssue ? ` (${firstIssue.path}: ${firstIssue.message})` : "."}`,
1125
+ source: "import",
1126
+ details: {
1127
+ issueCount: snapshotIssues.length,
1128
+ firstIssuePath: firstIssue?.path,
1129
+ },
1130
+ }),
1131
+ );
1132
+ }
1133
+
1134
+ // Build `initialSessionState` inline — bypassing
1135
+ // `editorSessionStateFromPersistedSnapshot`'s structuredClone so
1136
+ // `session.initialSessionState.canonicalDocument` is reference-equal
1137
+ // to `envelope.canonicalDocument` (cloning a large canonical document
1138
+ // defeats part of the cache gain).
1139
+ const sessionState: EditorSessionState = {
1140
+ sessionVersion: "editor-session-state/1",
1141
+ schemaVersion: snapshot.schemaVersion,
1142
+ documentId: snapshot.documentId,
1143
+ docId: snapshot.docId,
1144
+ createdAt: snapshot.createdAt,
1145
+ updatedAt: snapshot.updatedAt,
1146
+ editorBuild: snapshot.editorBuild,
1147
+ canonicalDocument: snapshot.canonicalDocument,
1148
+ compatibility: snapshot.compatibility,
1149
+ warningLog: snapshot.warningLog,
1150
+ protectionSnapshot: snapshot.protectionSnapshot,
1151
+ sourcePackage: snapshot.sourcePackage,
1152
+ workflowOverlay: snapshot.workflowOverlay,
1153
+ workflowMetadata: snapshot.workflowMetadata,
1154
+ };
1155
+
1156
+ // The short-circuit path does not carry an `ImportedDocxState`, so
1157
+ // `exportDocx` lazily re-runs the cold path on first invocation and
1158
+ // memoizes. Keeps the warm-load fast while preserving byte-exact
1159
+ // export correctness.
1160
+ let lazyColdExport: LoadedDocxEditorSession["exportDocx"] | undefined;
1161
+ const exportDocx: LoadedDocxEditorSession["exportDocx"] = async (
1162
+ nextSessionStateOrSnapshot,
1163
+ exportOptions,
1164
+ ) => {
1165
+ if (!lazyColdExport) {
1166
+ const { laycacheEnvelope: _unused, ...coldOptions } = options;
1167
+ void _unused;
1168
+ const coldSession = await loadDocxEditorSessionAsync(coldOptions);
1169
+ if (coldSession.fatalError) {
1170
+ throw new Error(
1171
+ `DOCX export via short-circuit fallback failed cold load: ${coldSession.fatalError.message ?? "fatal error"}`,
1172
+ );
1173
+ }
1174
+ lazyColdExport = coldSession.exportDocx;
1175
+ }
1176
+ return lazyColdExport(nextSessionStateOrSnapshot, exportOptions);
1177
+ };
1178
+
1179
+ stages.emit("skeleton-ready");
1180
+ return {
1181
+ initialSessionState: sessionState,
1182
+ initialSnapshot: snapshot,
1183
+ readOnly: false,
1184
+ protectionSnapshot,
1185
+ exportDocx,
1186
+ ...(embeddedWorkflowPayload?.editorState
1187
+ ? { initialEditorStatePayload: embeddedWorkflowPayload.editorState }
1188
+ : {}),
1189
+ };
1190
+ }
1191
+
1009
1192
  const sourceDocumentXml = decodeUtf8(documentPart.bytes);
1010
1193
  const importedRevisions = parseRevisionsFromDocumentXml(sourceDocumentXml);
1011
1194
  const numberingPartPath = resolveDocumentRelatedPartPath(
@@ -1021,11 +1204,17 @@ export async function loadDocxEditorSessionAsync(
1021
1204
  )
1022
1205
  : createEmptyNumberingCatalog();
1023
1206
  const mediaParts = collectInlineMediaParts(sourcePackage);
1207
+ const chartPartLookup = createChartPartLookup(
1208
+ sourcePackage,
1209
+ mainDocumentPath,
1210
+ documentPart.relationships,
1211
+ );
1024
1212
  const parsedDocument = parseMainDocumentXml(
1025
1213
  sourceDocumentXml,
1026
1214
  documentPart.relationships,
1027
1215
  mediaParts,
1028
1216
  mainDocumentPath,
1217
+ chartPartLookup,
1029
1218
  );
1030
1219
  await scheduler.yield();
1031
1220
  const protectionRanges = extractProtectionRanges(parsedDocument.blocks);
@@ -1440,6 +1629,9 @@ export async function loadDocxEditorSessionAsync(
1440
1629
  relationship.type === NUMBERING_RELATIONSHIP_TYPE &&
1441
1630
  relationship.targetMode === "internal",
1442
1631
  )?.id,
1632
+ sourceSettingsPartPath: settingsPartPath,
1633
+ sourceSettingsXml:
1634
+ settingsXmlForProtection.length > 0 ? settingsXmlForProtection : undefined,
1443
1635
  sourceCommentsPartPath: commentsPartPath,
1444
1636
  sourceCommentsRelationshipId: documentPart.relationships.find(
1445
1637
  (relationship) =>
@@ -1734,23 +1926,29 @@ function exportDocxEditorSession(
1734
1926
  }
1735
1927
  }
1736
1928
 
1737
- const workflowPayloadPartPaths = resolveWorkflowPayloadPartPaths(
1738
- state.sourcePackage,
1739
- sessionState.documentId,
1740
- );
1929
+ // Settings.xml is owned when the source package carried one OR the canonical
1930
+ // model carries settings we need to re-emit. The `canReuse && signatureMatch`
1931
+ // short-circuit above already skips re-export entirely for no-edit sessions,
1932
+ // so every path that reaches here is willing to emit a rebuilt settings.xml.
1933
+ const settingsPartPath =
1934
+ state.sourceSettingsPartPath ?? SETTINGS_PART_PATH;
1935
+ const hasSettingsSurface =
1936
+ Boolean(state.sourceSettingsPartPath) ||
1937
+ exportedSubParts?.settings !== undefined;
1741
1938
 
1742
1939
  const exportSession = createExportSession(state.sourcePackage, [
1743
1940
  state.sourceDocumentPartPath,
1744
1941
  APP_PROPERTIES_PART_PATH,
1745
1942
  CORE_PROPERTIES_PART_PATH,
1746
- workflowPayloadPartPaths.payloadPartPath,
1747
- workflowPayloadPartPaths.itemPropsPartPath,
1943
+ WORKFLOW_PAYLOAD_PART_PATH,
1944
+ WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
1748
1945
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
1749
1946
  numberingPartPath,
1750
1947
  commentsPartPath,
1751
1948
  commentsExtendedPartPath,
1752
1949
  commentsIdsPartPath,
1753
1950
  peoplePartPath,
1951
+ ...(hasSettingsSurface ? [settingsPartPath] : []),
1754
1952
  ...subPartOwnedPaths,
1755
1953
  ]);
1756
1954
 
@@ -1784,6 +1982,22 @@ function exportDocxEditorSession(
1784
1982
  });
1785
1983
  }
1786
1984
 
1985
+ if (hasSettingsSurface) {
1986
+ // Canonical settings ∅ + no source settings → omit the owned-part write
1987
+ // (hasSettingsSurface is already false in that case). Otherwise route
1988
+ // through the graft serializer so unmodelled children round-trip via
1989
+ // source bytes while canonical mutations land.
1990
+ const canonicalSettings = exportedSubParts?.settings ?? {};
1991
+ const settingsXml = serializeSettingsXml(canonicalSettings, state.sourceSettingsXml);
1992
+ exportSession.replaceOwnedPart({
1993
+ path: settingsPartPath,
1994
+ bytes: new TextEncoder().encode(settingsXml),
1995
+ contentType:
1996
+ state.sourcePackage.parts.get(settingsPartPath)?.contentType ??
1997
+ WORD_SETTINGS_CONTENT_TYPE,
1998
+ });
1999
+ }
2000
+
1787
2001
  if (serializedComments.serializedCommentIds.length > 0 || state.sourceCommentsPartPath) {
1788
2002
  exportSession.replaceOwnedPart({
1789
2003
  path: commentsPartPath,
@@ -1977,14 +2191,7 @@ function exportDocxEditorSession(
1977
2191
  ensureHostMetadataParts(exportSession, state.sourcePackage, currentDocument);
1978
2192
  // Schema 1.2: pass through editorState payload collected by the runtime channel.
1979
2193
  const internalEditorState = (options as { _editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload } | undefined)?._editorState;
1980
- ensureWorkflowPayloadParts(
1981
- exportSession,
1982
- sessionState,
1983
- currentDocument,
1984
- state.sourcePackage,
1985
- internalEditorState,
1986
- workflowPayloadPartPaths,
1987
- );
2194
+ ensureWorkflowPayloadParts(exportSession, sessionState, currentDocument, state.sourcePackage, internalEditorState);
1988
2195
 
1989
2196
  return {
1990
2197
  bytes: exportSession.serialize(),
@@ -3711,7 +3918,6 @@ function ensureWorkflowPayloadParts(
3711
3918
  document: CanonicalDocumentEnvelope,
3712
3919
  sourcePackage: OpcPackage,
3713
3920
  editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload,
3714
- precomputedPartPaths?: { payloadPartPath: string; itemPropsPartPath: string },
3715
3921
  ): void {
3716
3922
  const payloadParts = buildWorkflowPayloadParts({
3717
3923
  sourcePackage,
@@ -3727,37 +3933,19 @@ function ensureWorkflowPayloadParts(
3727
3933
  return;
3728
3934
  }
3729
3935
 
3730
- // Export ownership is resolved before session creation, so any precomputed
3731
- // paths must stay in lockstep with buildWorkflowPayloadParts for the same
3732
- // source package and documentId.
3733
- if (precomputedPartPaths) {
3734
- if (
3735
- precomputedPartPaths.payloadPartPath !== payloadParts.payloadPartPath ||
3736
- precomputedPartPaths.itemPropsPartPath !== payloadParts.itemPropsPartPath
3737
- ) {
3738
- throw new Error(
3739
- `ensureWorkflowPayloadParts: precomputed payload part paths `
3740
- + `(${precomputedPartPaths.payloadPartPath} / ${precomputedPartPaths.itemPropsPartPath}) `
3741
- + `do not match buildWorkflowPayloadParts output `
3742
- + `(${payloadParts.payloadPartPath} / ${payloadParts.itemPropsPartPath}). `
3743
- + `This is a bug in the export orchestration.`,
3744
- );
3745
- }
3746
- }
3747
-
3748
3936
  const payloadPart = sourcePackage.parts.get(payloadParts.payloadPartPath);
3749
3937
  const itemPropsPart = sourcePackage.parts.get(payloadParts.itemPropsPartPath);
3750
3938
  const customPropsPart = sourcePackage.parts.get(WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH);
3751
3939
 
3752
3940
  exportSession.replaceOwnedPart({
3753
- path: precomputedPartPaths?.payloadPartPath ?? payloadParts.payloadPartPath,
3941
+ path: payloadParts.payloadPartPath,
3754
3942
  bytes: new TextEncoder().encode(payloadParts.payloadPartXml),
3755
3943
  contentType: payloadPart?.contentType ?? WORKFLOW_PAYLOAD_CONTENT_TYPE,
3756
3944
  relationships: payloadParts.payloadRelationships,
3757
3945
  compression: payloadPart?.compression,
3758
3946
  });
3759
3947
  exportSession.replaceOwnedPart({
3760
- path: precomputedPartPaths?.itemPropsPartPath ?? payloadParts.itemPropsPartPath,
3948
+ path: payloadParts.itemPropsPartPath,
3761
3949
  bytes: new TextEncoder().encode(payloadParts.itemPropsXml),
3762
3950
  contentType: itemPropsPart?.contentType ?? WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
3763
3951
  compression: itemPropsPart?.compression,
@@ -15,6 +15,7 @@ import type {
15
15
  } from "../../model/canonical-document.ts";
16
16
  import type { OpcRelationship } from "../ooxml/part-manifest.ts";
17
17
  import type { RevisionParagraphBoundary } from "../ooxml/revision-boundaries.ts";
18
+ import { SCOPE_MARKER_BOOKMARK_PREFIX } from "../ooxml/parse-scope-markers.ts";
18
19
  import { getOpaqueFragment } from "../../preservation/store.ts";
19
20
  import { retainRelationshipsForFragment } from "../../preservation/relationship-retention.ts";
20
21
  import { serializeParagraphNumberingProperties } from "./serialize-numbering.ts";
@@ -25,6 +26,7 @@ import {
25
26
  } from "./table-properties-xml.ts";
26
27
  import { twip } from "./twip.ts";
27
28
  import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
29
+ import { emitPropertyGrabBag } from "../ooxml/property-grab-bag.ts";
28
30
 
29
31
  const HYPERLINK_RELATIONSHIP_TYPE =
30
32
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
@@ -571,6 +573,21 @@ function serializeTableInlineNode(
571
573
  );
572
574
  case "bookmark_end":
573
575
  return `<w:bookmarkEnd w:id="${escapeXmlAttribute(node.bookmarkId)}"/>`;
576
+ case "scope_marker_start": {
577
+ // S1 — scope markers export as w:bookmarkStart with the reserved
578
+ // `bw:scope:` name prefix. The synthetic w:id is keyed on scopeId so
579
+ // the matching end element references the same id.
580
+ const bkId = `scope-${node.scopeId}`;
581
+ const name = `${SCOPE_MARKER_BOOKMARK_PREFIX}${node.scopeId}`;
582
+ return (
583
+ `<w:bookmarkStart w:id="${escapeXmlAttribute(bkId)}"` +
584
+ ` w:name="${escapeXmlAttribute(name)}"/>`
585
+ );
586
+ }
587
+ case "scope_marker_end": {
588
+ const bkId = `scope-${node.scopeId}`;
589
+ return `<w:bookmarkEnd w:id="${escapeXmlAttribute(bkId)}"/>`;
590
+ }
574
591
  case "footnote_ref": {
575
592
  const refElement =
576
593
  node.noteKind === "footnote"
@@ -1060,6 +1077,27 @@ function serializeInlineNode(
1060
1077
  boundaries.set(cursor, xmlOffset + xml.length);
1061
1078
  return { xml, cursor, boundaries };
1062
1079
  }
1080
+ case "scope_marker_start": {
1081
+ // S1 — mirror the bookmark_start shape with the reserved `bw:scope:`
1082
+ // name prefix. See serializeInline() above for the same convention.
1083
+ const bkId = `scope-${node.scopeId}`;
1084
+ const name = `${SCOPE_MARKER_BOOKMARK_PREFIX}${node.scopeId}`;
1085
+ const xml =
1086
+ `<w:bookmarkStart w:id="${escapeXmlAttribute(bkId)}"` +
1087
+ ` w:name="${escapeXmlAttribute(name)}"/>`;
1088
+ const boundaries = new Map<number, number>();
1089
+ boundaries.set(cursor, xmlOffset);
1090
+ boundaries.set(cursor, xmlOffset + xml.length);
1091
+ return { xml, cursor, boundaries };
1092
+ }
1093
+ case "scope_marker_end": {
1094
+ const bkId = `scope-${node.scopeId}`;
1095
+ const xml = `<w:bookmarkEnd w:id="${escapeXmlAttribute(bkId)}"/>`;
1096
+ const boundaries = new Map<number, number>();
1097
+ boundaries.set(cursor, xmlOffset);
1098
+ boundaries.set(cursor, xmlOffset + xml.length);
1099
+ return { xml, cursor, boundaries };
1100
+ }
1063
1101
  case "footnote_ref": {
1064
1102
  const refElement =
1065
1103
  node.noteKind === "footnote"
@@ -1570,6 +1608,14 @@ export function serializeSectionPropertiesXml(props: SectionProperties): string
1570
1608
  }
1571
1609
  }
1572
1610
 
1611
+ // O2 Slice 4 grab-bag: unmodelled sectPr children preserved verbatim
1612
+ // from import. Emitted last so the modelled body stays ECMA-376 canonical
1613
+ // and extension-namespace properties land at the tail of <w:sectPr>.
1614
+ const grabBagXml = emitPropertyGrabBag(props.unknownPropertyChildren);
1615
+ if (grabBagXml.length > 0) {
1616
+ children.push(grabBagXml);
1617
+ }
1618
+
1573
1619
  if (children.length === 0) {
1574
1620
  return "<w:sectPr/>";
1575
1621
  }
@@ -12,6 +12,7 @@ import type {
12
12
  ParagraphSpacing,
13
13
  TabStop,
14
14
  } from "../../model/canonical-document.ts";
15
+ import { emitPropertyGrabBag } from "../ooxml/property-grab-bag.ts";
15
16
  import { buildRunPropertiesXml } from "./serialize-run-formatting.ts";
16
17
 
17
18
  function escXml(value: string): string {
@@ -147,6 +148,13 @@ export function buildParagraphPropertiesXml(
147
148
  if (markXml) parts.push(markXml);
148
149
  }
149
150
 
151
+ // 16. Grab-bag: unmodelled pPr children preserved verbatim from import.
152
+ // Emitted last so the typed emit order above stays ECMA-376 canonical and
153
+ // any extension-namespace properties land after the modelled set. Word
154
+ // tolerates extra trailing children inside <w:pPr> better than it
155
+ // tolerates interleaving them with the typed set.
156
+ parts.push(emitPropertyGrabBag(pPr.unknownPropertyChildren));
157
+
150
158
  const body = parts.filter(Boolean).join("");
151
159
  return body.length > 0 ? `<w:pPr>${body}</w:pPr>` : "";
152
160
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { CanonicalRunFormatting } from "../../model/canonical-document.ts";
11
+ import { emitPropertyGrabBag } from "../ooxml/property-grab-bag.ts";
11
12
 
12
13
  function escXml(value: string): string {
13
14
  return value
@@ -57,10 +58,12 @@ export function buildRunPropertiesXml(rPr: CanonicalRunFormatting | undefined):
57
58
  parts.push(toggleEl("vanish", rPr.vanish));
58
59
 
59
60
  // 8. color
60
- if (rPr.colorHex || rPr.colorThemeSlot) {
61
+ if (rPr.colorHex || rPr.colorThemeSlot || rPr.colorThemeTint || rPr.colorThemeShade) {
61
62
  const attrs: string[] = [];
62
63
  if (rPr.colorHex) attrs.push(`w:val="${escXml(rPr.colorHex)}"`);
63
64
  if (rPr.colorThemeSlot) attrs.push(`w:themeColor="${escXml(rPr.colorThemeSlot)}"`);
65
+ if (rPr.colorThemeTint) attrs.push(`w:themeTint="${escXml(rPr.colorThemeTint)}"`);
66
+ if (rPr.colorThemeShade) attrs.push(`w:themeShade="${escXml(rPr.colorThemeShade)}"`);
64
67
  parts.push(`<w:color ${attrs.join(" ")}/>`);
65
68
  }
66
69
 
@@ -85,6 +88,12 @@ export function buildRunPropertiesXml(rPr: CanonicalRunFormatting | undefined):
85
88
  if (rPr.fontSizeHalfPoints !== undefined) parts.push(`<w:sz w:val="${rPr.fontSizeHalfPoints}"/>`);
86
89
  if (rPr.fontSizeCsHalfPoints !== undefined) parts.push(`<w:szCs w:val="${rPr.fontSizeCsHalfPoints}"/>`);
87
90
 
91
+ // 15. Grab-bag: unmodelled rPr children preserved verbatim from import
92
+ // (extension-namespace properties like <w14:textOutline>, Word-internal
93
+ // knobs like <w:em>, <w:kern>). Emitted last so the typed body stays
94
+ // ECMA-376 canonical and Word tolerates extras at the tail of <w:rPr>.
95
+ parts.push(emitPropertyGrabBag(rPr.unknownPropertyChildren));
96
+
88
97
  const body = parts.filter(Boolean).join("");
89
98
  return body.length > 0 ? `<w:rPr>${body}</w:rPr>` : "";
90
99
  }