@beyondwork/docx-react-component 1.0.46 → 1.0.48

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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +115 -1
  3. package/src/compare/diff-engine.ts +4 -0
  4. package/src/core/commands/add-scope.ts +257 -0
  5. package/src/core/commands/formatting-commands.ts +2 -0
  6. package/src/core/commands/index.ts +120 -1
  7. package/src/core/schema/text-schema.ts +95 -1
  8. package/src/core/state/text-transaction.ts +17 -5
  9. package/src/io/chart-preview-resolver.ts +27 -0
  10. package/src/io/docx-session.ts +219 -2
  11. package/src/io/export/serialize-main-document.ts +37 -0
  12. package/src/io/export/serialize-settings.ts +421 -0
  13. package/src/io/export/serialize-styles.ts +10 -0
  14. package/src/io/normalize/normalize-text.ts +1 -0
  15. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  16. package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
  17. package/src/io/ooxml/chart/parse-series.ts +570 -0
  18. package/src/io/ooxml/chart/resolve-color.ts +251 -0
  19. package/src/io/ooxml/chart/types.ts +420 -0
  20. package/src/io/ooxml/parse-block-structure.ts +99 -0
  21. package/src/io/ooxml/parse-complex-content.ts +87 -2
  22. package/src/io/ooxml/parse-main-document.ts +115 -1
  23. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  24. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  25. package/src/io/ooxml/parse-settings.ts +97 -1
  26. package/src/io/ooxml/parse-styles.ts +65 -0
  27. package/src/io/ooxml/parse-theme.ts +2 -127
  28. package/src/io/ooxml/workflow-payload.ts +27 -0
  29. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  30. package/src/io/ooxml/xml-parser.ts +142 -0
  31. package/src/model/canonical-document.ts +94 -0
  32. package/src/model/scope-markers.ts +144 -0
  33. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  34. package/src/runtime/collab/checkpoint-election.ts +75 -0
  35. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  36. package/src/runtime/collab/checkpoint-store.ts +115 -0
  37. package/src/runtime/collab/event-types.ts +37 -5
  38. package/src/runtime/collab/index.ts +22 -0
  39. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  40. package/src/runtime/collab/runtime-collab-sync.ts +404 -1
  41. package/src/runtime/document-runtime.ts +221 -16
  42. package/src/runtime/editor-surface/capabilities.ts +63 -50
  43. package/src/runtime/layout/layout-engine-version.ts +27 -2
  44. package/src/runtime/prerender/cache-envelope.ts +19 -7
  45. package/src/runtime/prerender/cache-key.ts +25 -14
  46. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  47. package/src/runtime/prerender/customxml-cache.ts +211 -0
  48. package/src/runtime/prerender/customxml-probe.ts +78 -0
  49. package/src/runtime/prerender/prerender-document.ts +74 -7
  50. package/src/runtime/scope-resolver.ts +148 -0
  51. package/src/runtime/scope-tag-registry.ts +10 -0
  52. package/src/runtime/surface-projection.ts +8 -1
  53. package/src/runtime/text-ack-range.ts +3 -3
  54. package/src/ui/WordReviewEditor.tsx +30 -0
  55. package/src/ui/editor-runtime-boundary.ts +6 -1
  56. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
@@ -26,6 +26,7 @@ export type StoryUnit =
26
26
  | ImageUnit
27
27
  | OpaqueInlineUnit
28
28
  | OpaqueBlockUnit
29
+ | ScopeMarkerUnit
29
30
  | ParagraphBreakUnit;
30
31
 
31
32
  export interface TextCharacterUnit {
@@ -69,6 +70,18 @@ export interface ParagraphBreakUnit {
69
70
  nextParagraph: ParagraphProperties;
70
71
  }
71
72
 
73
+ /**
74
+ * Zero-width inline unit that preserves S1 scope-marker nodes through text
75
+ * transactions. Without this unit, scope markers would be silently dropped
76
+ * during `parseTextStory` / `serializeTextStory`, and any `text.insert` /
77
+ * `text.delete-*` dispatch would vaporize the structural scope anchors.
78
+ */
79
+ export interface ScopeMarkerUnit {
80
+ kind: "scope_marker";
81
+ boundary: "start" | "end";
82
+ scopeId: string;
83
+ }
84
+
72
85
  export function parseTextStory(content: unknown): TextStory {
73
86
  const root = normalizeDocumentRoot(content);
74
87
  const firstParagraphNode = root.children.find(isParagraphNode);
@@ -111,10 +124,60 @@ export function parseTextStory(content: unknown): TextStory {
111
124
  return {
112
125
  firstParagraph,
113
126
  units,
114
- size: units.length,
127
+ size: countLogicalPositions(units),
115
128
  };
116
129
  }
117
130
 
131
+ /**
132
+ * Story positions are logical — scope-marker units are preserved in the
133
+ * `units` array for round-trip fidelity but they do NOT consume a position.
134
+ * This matches the surface-projection treatment (markers = 0 width, same as
135
+ * bookmark_start / bookmark_end) so a position 3 set via `selection.set`
136
+ * resolves to the same character in both views.
137
+ */
138
+ export function countLogicalPositions(units: StoryUnit[]): number {
139
+ let size = 0;
140
+ for (const unit of units) {
141
+ if (unit.kind !== "scope_marker") size += 1;
142
+ }
143
+ return size;
144
+ }
145
+
146
+ /**
147
+ * Translate a logical (scope-marker-skipping) position into a unit-array
148
+ * index. Walks units and increments the unit cursor once per non-marker unit;
149
+ * scope markers are passed over transparently. Returns `units.length` when
150
+ * the logical position is at or beyond end-of-story.
151
+ *
152
+ * When `startBias === "after"` (default), the returned unit index is the
153
+ * first position AFTER any scope markers that sit exactly at the logical
154
+ * boundary — useful when slicing units as "...before this cursor". When
155
+ * `startBias === "before"`, markers at the boundary are included in the
156
+ * "after" slice.
157
+ */
158
+ export function logicalPositionToUnitIndex(
159
+ units: StoryUnit[],
160
+ logicalPos: number,
161
+ startBias: "before" | "after" = "after",
162
+ ): number {
163
+ let logicalCursor = 0;
164
+ let unitIndex = 0;
165
+ while (unitIndex < units.length) {
166
+ if (logicalCursor === logicalPos && startBias === "before") {
167
+ return unitIndex;
168
+ }
169
+ const unit = units[unitIndex]!;
170
+ if (unit.kind !== "scope_marker") {
171
+ if (logicalCursor === logicalPos && startBias === "after") {
172
+ return unitIndex;
173
+ }
174
+ logicalCursor += 1;
175
+ }
176
+ unitIndex += 1;
177
+ }
178
+ return unitIndex;
179
+ }
180
+
118
181
  export function serializeTextStory(story: TextStory): DocumentRootNode {
119
182
  const blocks: Array<ParagraphNode | OpaqueBlockNode> = [];
120
183
  let currentParagraph: ParagraphNode | undefined = createParagraph(story.firstParagraph);
@@ -272,6 +335,15 @@ export function serializeTextStory(story: TextStory): DocumentRootNode {
272
335
  warningId: unit.warningId,
273
336
  });
274
337
  break;
338
+ case "scope_marker":
339
+ pushInlineNode({
340
+ type:
341
+ unit.boundary === "start"
342
+ ? "scope_marker_start"
343
+ : "scope_marker_end",
344
+ scopeId: unit.scopeId,
345
+ });
346
+ break;
275
347
  }
276
348
  }
277
349
 
@@ -305,6 +377,8 @@ export function createPlainText(story: TextStory): string {
305
377
  return "\uFFF9";
306
378
  case "opaque_block":
307
379
  return "\uFFFA";
380
+ case "scope_marker":
381
+ return "";
308
382
  }
309
383
  })
310
384
  .join("");
@@ -355,6 +429,12 @@ export function cloneStoryUnit(unit: StoryUnit): StoryUnit {
355
429
  kind: "paragraph_break",
356
430
  nextParagraph: cloneParagraphProperties(unit.nextParagraph),
357
431
  };
432
+ case "scope_marker":
433
+ return {
434
+ kind: "scope_marker",
435
+ boundary: unit.boundary,
436
+ scopeId: unit.scopeId,
437
+ };
358
438
  }
359
439
  }
360
440
 
@@ -442,6 +522,20 @@ function flattenInlineNodes(
442
522
  warningId: node.warningId,
443
523
  });
444
524
  break;
525
+ case "scope_marker_start":
526
+ units.push({
527
+ kind: "scope_marker",
528
+ boundary: "start",
529
+ scopeId: node.scopeId,
530
+ });
531
+ break;
532
+ case "scope_marker_end":
533
+ units.push({
534
+ kind: "scope_marker",
535
+ boundary: "end",
536
+ scopeId: node.scopeId,
537
+ });
538
+ break;
445
539
  }
446
540
  }
447
541
 
@@ -3,7 +3,9 @@ import type { TransactionMapping } from "../selection/mapping.ts";
3
3
  import {
4
4
  cloneParagraphProperties,
5
5
  cloneStoryUnit,
6
+ countLogicalPositions,
6
7
  createPlainText,
8
+ logicalPositionToUnitIndex,
7
9
  parseTextStory,
8
10
  serializeTextStory,
9
11
  type ParagraphProperties,
@@ -117,12 +119,18 @@ function applyLinearTextTransaction(
117
119
  const normalizedRange = resolveRange(selection, story.size, intent);
118
120
  const insertionUnits = createInsertionUnits(intent, story, normalizedRange.from);
119
121
 
120
- ensureEditableRange(story.units.slice(normalizedRange.from, normalizedRange.to));
122
+ // `normalizedRange.{from,to}` are logical positions (scope markers = 0 width,
123
+ // matching surface-projection). Translate to unit-array indices so scope
124
+ // marker units preserved at the boundary stay intact on either side.
125
+ const unitFrom = logicalPositionToUnitIndex(story.units, normalizedRange.from, "before");
126
+ const unitTo = logicalPositionToUnitIndex(story.units, normalizedRange.to, "after");
127
+
128
+ ensureEditableRange(story.units.slice(unitFrom, unitTo));
121
129
 
122
130
  const nextUnits = [
123
- ...story.units.slice(0, normalizedRange.from).map(cloneStoryUnit),
131
+ ...story.units.slice(0, unitFrom).map(cloneStoryUnit),
124
132
  ...insertionUnits.map(cloneStoryUnit),
125
- ...story.units.slice(normalizedRange.to).map(cloneStoryUnit),
133
+ ...story.units.slice(unitTo).map(cloneStoryUnit),
126
134
  ];
127
135
 
128
136
  const nextStory: TextStory = {
@@ -130,9 +138,13 @@ function applyLinearTextTransaction(
130
138
  units: normalizeStoryUnits(nextUnits),
131
139
  size: 0,
132
140
  };
133
- nextStory.size = nextStory.units.length;
141
+ nextStory.size = countLogicalPositions(nextStory.units);
134
142
 
135
- const caret = normalizedRange.from + insertionUnits.length;
143
+ // `normalizedRange.from` is the logical insertion point; count the logical
144
+ // positions added by `insertionUnits` (skipping any scope markers) to derive
145
+ // the post-insert caret.
146
+ const logicalInsertionSize = countLogicalPositions(insertionUnits);
147
+ const caret = normalizedRange.from + logicalInsertionSize;
136
148
 
137
149
  return {
138
150
  document: {
@@ -214,6 +214,33 @@ function extractPartTextFromPackage(pkg: OpcPackage, path: string): string | und
214
214
  }
215
215
  }
216
216
 
217
+ /**
218
+ * Build a chart-part lookup callback suitable for
219
+ * `parseMainDocumentXml(..., chartPartLookup)`.
220
+ *
221
+ * The callback is called synchronously during parsing with a chart
222
+ * relationship id (the `r:id` on a `<c:chart>` reference). It resolves
223
+ * the id to a chart-part target path via the document's relationship
224
+ * table, then decodes the matching package part's bytes as UTF-8. Unknown
225
+ * ids and missing parts return undefined, in which case the parser
226
+ * proceeds without a typed `ChartModel` (the drawing still produces a
227
+ * `ChartPreviewNode` with `rawXml`).
228
+ */
229
+ export function createChartPartLookup(
230
+ pkg: OpcPackage,
231
+ documentPartPath: string,
232
+ documentRelationships: readonly import("./ooxml/part-manifest.ts").OpcRelationship[],
233
+ ): (rId: string) => string | undefined {
234
+ const relById = new Map(documentRelationships.map((r) => [r.id, r]));
235
+ return (rId: string): string | undefined => {
236
+ const rel = relById.get(rId);
237
+ if (!rel) return undefined;
238
+ const target = resolveRelationshipTarget(documentPartPath, rel);
239
+ if (!target) return undefined;
240
+ return extractPartTextFromPackage(pkg, normalizePartPath(target));
241
+ };
242
+ }
243
+
217
244
  /**
218
245
  * Produce a new CanonicalDocument with the resolved chart_preview
219
246
  * nodes carrying previewMediaId + corresponding MediaCatalog entries.
@@ -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,
@@ -76,6 +77,10 @@ import {
76
77
  import { buildAppPropertiesXml } from "./export/build-app-properties-xml.ts";
77
78
  import { createExportSession } from "./export/export-session.ts";
78
79
  import { serializeMainDocument } from "./export/serialize-main-document.ts";
80
+ import {
81
+ serializeSettingsXml,
82
+ WORD_SETTINGS_CONTENT_TYPE,
83
+ } from "./export/serialize-settings.ts";
79
84
  import {
80
85
  parseRevisionsFromDocumentXml,
81
86
  parseRevisionsFromStoryXml,
@@ -264,6 +269,21 @@ interface ImportedDocxState {
264
269
  sourceDocumentAttributes: Record<string, string>;
265
270
  sourceNumberingPartPath?: string;
266
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;
267
287
  sourceCommentsPartPath?: string;
268
288
  sourceCommentsRelationshipId?: string;
269
289
  sourceCommentsRootTag?: string;
@@ -428,11 +448,17 @@ export function loadDocxEditorSession(
428
448
  )
429
449
  : createEmptyNumberingCatalog();
430
450
  const mediaParts = collectInlineMediaParts(sourcePackage);
451
+ const chartPartLookup = createChartPartLookup(
452
+ sourcePackage,
453
+ mainDocumentPath,
454
+ documentPart.relationships,
455
+ );
431
456
  const parsedDocument = parseMainDocumentXml(
432
457
  sourceDocumentXml,
433
458
  documentPart.relationships,
434
459
  mediaParts,
435
460
  mainDocumentPath,
461
+ chartPartLookup,
436
462
  );
437
463
  const protectionRanges = extractProtectionRanges(parsedDocument.blocks);
438
464
  const normalizedDocument = normalizeParsedTextDocument(
@@ -831,6 +857,9 @@ export function loadDocxEditorSession(
831
857
  relationship.type === NUMBERING_RELATIONSHIP_TYPE &&
832
858
  relationship.targetMode === "internal",
833
859
  )?.id,
860
+ sourceSettingsPartPath: settingsPartPath,
861
+ sourceSettingsXml:
862
+ settingsXmlForProtection.length > 0 ? settingsXmlForProtection : undefined,
834
863
  sourceCommentsPartPath: commentsPartPath,
835
864
  sourceCommentsRelationshipId: documentPart.relationships.find(
836
865
  (relationship) =>
@@ -898,7 +927,7 @@ export function loadDocxEditorSession(
898
927
  }
899
928
  }
900
929
 
901
- interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSessionOptions {
930
+ export interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSessionOptions {
902
931
  /**
903
932
  * Scheduler that the async loader awaits between parse stages. Callers
904
933
  * in DOM environments should construct this with `createLoadScheduler()`
@@ -909,6 +938,27 @@ interface LoadDocxEditorSessionAsyncOptions extends LoadDocxEditorSessionOptions
909
938
  * behaves like the sync path from the test harness POV.
910
939
  */
911
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;
912
962
  }
913
963
 
914
964
  /**
@@ -1008,6 +1058,137 @@ export async function loadDocxEditorSessionAsync(
1008
1058
  }
1009
1059
 
1010
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
+
1011
1192
  const sourceDocumentXml = decodeUtf8(documentPart.bytes);
1012
1193
  const importedRevisions = parseRevisionsFromDocumentXml(sourceDocumentXml);
1013
1194
  const numberingPartPath = resolveDocumentRelatedPartPath(
@@ -1023,11 +1204,17 @@ export async function loadDocxEditorSessionAsync(
1023
1204
  )
1024
1205
  : createEmptyNumberingCatalog();
1025
1206
  const mediaParts = collectInlineMediaParts(sourcePackage);
1207
+ const chartPartLookup = createChartPartLookup(
1208
+ sourcePackage,
1209
+ mainDocumentPath,
1210
+ documentPart.relationships,
1211
+ );
1026
1212
  const parsedDocument = parseMainDocumentXml(
1027
1213
  sourceDocumentXml,
1028
1214
  documentPart.relationships,
1029
1215
  mediaParts,
1030
1216
  mainDocumentPath,
1217
+ chartPartLookup,
1031
1218
  );
1032
1219
  await scheduler.yield();
1033
1220
  const protectionRanges = extractProtectionRanges(parsedDocument.blocks);
@@ -1442,6 +1629,9 @@ export async function loadDocxEditorSessionAsync(
1442
1629
  relationship.type === NUMBERING_RELATIONSHIP_TYPE &&
1443
1630
  relationship.targetMode === "internal",
1444
1631
  )?.id,
1632
+ sourceSettingsPartPath: settingsPartPath,
1633
+ sourceSettingsXml:
1634
+ settingsXmlForProtection.length > 0 ? settingsXmlForProtection : undefined,
1445
1635
  sourceCommentsPartPath: commentsPartPath,
1446
1636
  sourceCommentsRelationshipId: documentPart.relationships.find(
1447
1637
  (relationship) =>
@@ -1736,6 +1926,16 @@ function exportDocxEditorSession(
1736
1926
  }
1737
1927
  }
1738
1928
 
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;
1938
+
1739
1939
  const exportSession = createExportSession(state.sourcePackage, [
1740
1940
  state.sourceDocumentPartPath,
1741
1941
  APP_PROPERTIES_PART_PATH,
@@ -1748,6 +1948,7 @@ function exportDocxEditorSession(
1748
1948
  commentsExtendedPartPath,
1749
1949
  commentsIdsPartPath,
1750
1950
  peoplePartPath,
1951
+ ...(hasSettingsSurface ? [settingsPartPath] : []),
1751
1952
  ...subPartOwnedPaths,
1752
1953
  ]);
1753
1954
 
@@ -1781,6 +1982,22 @@ function exportDocxEditorSession(
1781
1982
  });
1782
1983
  }
1783
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
+
1784
2001
  if (serializedComments.serializedCommentIds.length > 0 || state.sourceCommentsPartPath) {
1785
2002
  exportSession.replaceOwnedPart({
1786
2003
  path: commentsPartPath,
@@ -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";
@@ -571,6 +572,21 @@ function serializeTableInlineNode(
571
572
  );
572
573
  case "bookmark_end":
573
574
  return `<w:bookmarkEnd w:id="${escapeXmlAttribute(node.bookmarkId)}"/>`;
575
+ case "scope_marker_start": {
576
+ // S1 — scope markers export as w:bookmarkStart with the reserved
577
+ // `bw:scope:` name prefix. The synthetic w:id is keyed on scopeId so
578
+ // the matching end element references the same id.
579
+ const bkId = `scope-${node.scopeId}`;
580
+ const name = `${SCOPE_MARKER_BOOKMARK_PREFIX}${node.scopeId}`;
581
+ return (
582
+ `<w:bookmarkStart w:id="${escapeXmlAttribute(bkId)}"` +
583
+ ` w:name="${escapeXmlAttribute(name)}"/>`
584
+ );
585
+ }
586
+ case "scope_marker_end": {
587
+ const bkId = `scope-${node.scopeId}`;
588
+ return `<w:bookmarkEnd w:id="${escapeXmlAttribute(bkId)}"/>`;
589
+ }
574
590
  case "footnote_ref": {
575
591
  const refElement =
576
592
  node.noteKind === "footnote"
@@ -1060,6 +1076,27 @@ function serializeInlineNode(
1060
1076
  boundaries.set(cursor, xmlOffset + xml.length);
1061
1077
  return { xml, cursor, boundaries };
1062
1078
  }
1079
+ case "scope_marker_start": {
1080
+ // S1 — mirror the bookmark_start shape with the reserved `bw:scope:`
1081
+ // name prefix. See serializeInline() above for the same convention.
1082
+ const bkId = `scope-${node.scopeId}`;
1083
+ const name = `${SCOPE_MARKER_BOOKMARK_PREFIX}${node.scopeId}`;
1084
+ const xml =
1085
+ `<w:bookmarkStart w:id="${escapeXmlAttribute(bkId)}"` +
1086
+ ` w:name="${escapeXmlAttribute(name)}"/>`;
1087
+ const boundaries = new Map<number, number>();
1088
+ boundaries.set(cursor, xmlOffset);
1089
+ boundaries.set(cursor, xmlOffset + xml.length);
1090
+ return { xml, cursor, boundaries };
1091
+ }
1092
+ case "scope_marker_end": {
1093
+ const bkId = `scope-${node.scopeId}`;
1094
+ const xml = `<w:bookmarkEnd w:id="${escapeXmlAttribute(bkId)}"/>`;
1095
+ const boundaries = new Map<number, number>();
1096
+ boundaries.set(cursor, xmlOffset);
1097
+ boundaries.set(cursor, xmlOffset + xml.length);
1098
+ return { xml, cursor, boundaries };
1099
+ }
1063
1100
  case "footnote_ref": {
1064
1101
  const refElement =
1065
1102
  node.noteKind === "footnote"