@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -1,12 +1,16 @@
1
1
  import type {
2
2
  CompatibilityReport as PublicCompatibilityReport,
3
3
  EditorError,
4
+ EditorSessionState,
4
5
  EditorWarning as PublicEditorWarning,
5
6
  EditorAnchorProjection as PublicEditorAnchorProjection,
6
7
  ExportDocxOptions,
7
8
  ExportResult,
8
9
  PersistedEditorSnapshot,
10
+ ProtectionRange,
11
+ ProtectionSnapshot,
9
12
  } from "../api/public-types.ts";
13
+ import { editorSessionStateFromPersistedSnapshot } from "../api/session-state.ts";
10
14
  import type {
11
15
  CanonicalDocumentEnvelope,
12
16
  CompatibilityFeatureEntry as InternalCompatibilityFeatureEntry,
@@ -23,7 +27,12 @@ import {
23
27
  } from "../core/selection/mapping.ts";
24
28
  import { DOCX_MIME_TYPE } from "./opc/docx-package.ts";
25
29
  import { readOpcPackage, type OpcPackage } from "./opc/package-reader.ts";
26
- import { parseMainDocumentXml } from "./ooxml/parse-main-document.ts";
30
+ import {
31
+ parseMainDocumentXml,
32
+ type ParsedBlockNode,
33
+ type ParsedInlineNode,
34
+ type ParsedPermStartInlineNode,
35
+ } from "./ooxml/parse-main-document.ts";
27
36
  import { normalizeParsedTextDocument } from "./normalize/normalize-text.ts";
28
37
  import {
29
38
  CONTENT_TYPES_PATH,
@@ -45,6 +54,7 @@ import { parseCommentsFromOoxml } from "./ooxml/parse-comments.ts";
45
54
  import { parseNumberingXml } from "./ooxml/parse-numbering.ts";
46
55
  import {
47
56
  createCommentExportIdMap,
57
+ mapParagraphBoundaries,
48
58
  serializeCommentAnchorsIntoDocumentXml,
49
59
  serializeMergedCommentsXml,
50
60
  } from "./export/serialize-comments.ts";
@@ -80,6 +90,7 @@ import type {
80
90
  import { createReadOnlyDiagnosticsRuntime } from "../runtime/read-only-diagnostics-runtime.ts";
81
91
  import {
82
92
  WORD_NUMBERING_CONTENT_TYPE,
93
+ hasSerializableNumberingEntries,
83
94
  serializeNumberingXml,
84
95
  } from "./export/serialize-numbering.ts";
85
96
  import {
@@ -89,6 +100,9 @@ import {
89
100
  } from "./ooxml/parse-headers-footers.ts";
90
101
  import { parseFootnotesXml, parseEndnotesXml } from "./ooxml/parse-footnotes.ts";
91
102
  import { parseThemeXml } from "./ooxml/parse-theme.ts";
103
+ import { resolveTheme } from "./ooxml/parse-theme.ts";
104
+ import { parseSettingsXml } from "./ooxml/parse-settings.ts";
105
+ import { parseStylesXml, type ParseStylesResult } from "./ooxml/parse-styles.ts";
92
106
  import {
93
107
  serializeHeaderXml,
94
108
  serializeFooterXml,
@@ -102,6 +116,11 @@ import {
102
116
  WORD_ENDNOTES_CONTENT_TYPE,
103
117
  } from "./export/serialize-footnotes.ts";
104
118
  import { createPersistedSourcePackage } from "./source-package-provenance.ts";
119
+ import { validatePersistedEditorSnapshot } from "../model/snapshot.ts";
120
+ import {
121
+ createSyntheticDocxNullNumberingCatalog,
122
+ DOCX_NULL_NUMBERING_INSTANCE_ID,
123
+ } from "./ooxml/numbering-sentinels.ts";
105
124
 
106
125
  const MAIN_DOCUMENT_PATH = "/word/document.xml";
107
126
  const NUMBERING_PART_PATH = "/word/numbering.xml";
@@ -149,22 +168,30 @@ const FOOTNOTES_RELATIONSHIP_TYPE =
149
168
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
150
169
  const ENDNOTES_RELATIONSHIP_TYPE =
151
170
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
171
+ const SETTINGS_RELATIONSHIP_TYPE =
172
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
173
+ const STYLES_RELATIONSHIP_TYPE =
174
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
175
+ const STYLES_PART_PATH = "/word/styles.xml";
152
176
  const FOOTNOTES_PART_PATH = "/word/footnotes.xml";
153
177
  const ENDNOTES_PART_PATH = "/word/endnotes.xml";
178
+ const SETTINGS_PART_PATH = "/word/settings.xml";
154
179
 
155
180
  interface LoadDocxEditorSessionOptions {
156
181
  documentId: string;
157
182
  sourceLabel?: string;
158
183
  bytes: Uint8Array | ArrayBuffer;
159
- editorBuild: string;
184
+ editorBuild?: string;
160
185
  }
161
186
 
162
187
  export interface LoadedDocxEditorSession {
188
+ initialSessionState: EditorSessionState;
163
189
  initialSnapshot: PersistedEditorSnapshot;
164
190
  fatalError?: EditorError;
165
191
  readOnly: boolean;
192
+ protectionSnapshot: ProtectionSnapshot;
166
193
  exportDocx: (
167
- snapshot: PersistedEditorSnapshot,
194
+ sessionState: EditorSessionState | PersistedEditorSnapshot,
168
195
  options?: ExportDocxOptions,
169
196
  ) => Promise<ExportResult>;
170
197
  }
@@ -190,6 +217,7 @@ interface ImportedDocxState {
190
217
  sourcePeopleRelationshipId?: string;
191
218
  sourcePeopleRootTag?: string;
192
219
  sourcePeopleAuthors: readonly string[];
220
+ protectionSnapshot: ProtectionSnapshot;
193
221
  preservedCommentDefinitions: readonly ImportedCommentDefinition[];
194
222
  blockingCommentDiagnostics: readonly CommentImportDiagnostic[];
195
223
  initialCanonicalSignature: string;
@@ -220,6 +248,10 @@ const BLOCKING_COMMENT_DIAGNOSTIC_CODES = new Set<CommentImportDiagnostic["code"
220
248
  export function loadDocxEditorSession(
221
249
  options: LoadDocxEditorSessionOptions,
222
250
  ): LoadedDocxEditorSession {
251
+ const editorBuild =
252
+ typeof options.editorBuild === "string" && options.editorBuild.length > 0
253
+ ? options.editorBuild
254
+ : "dev";
223
255
  const sourceBytes = toUint8Array(options.bytes);
224
256
  let sourcePackage: OpcPackage;
225
257
 
@@ -304,6 +336,7 @@ export function loadDocxEditorSession(
304
336
  mediaParts,
305
337
  mainDocumentPath,
306
338
  );
339
+ const protectionRanges = extractProtectionRanges(parsedDocument.blocks);
307
340
  const normalizedDocument = normalizeParsedTextDocument(
308
341
  parsedDocument,
309
342
  mainDocumentPath,
@@ -376,13 +409,14 @@ export function loadDocxEditorSession(
376
409
  const parsedFooters: FooterDocument[] = [];
377
410
  const sourceHeaderPaths: Array<{ partPath: string; relationshipId: string }> = [];
378
411
  const sourceFooterPaths: Array<{ partPath: string; relationshipId: string }> = [];
379
- const seenSubPartRelIds = new Set<string>();
412
+ const seenSubPartKeys = new Set<string>();
380
413
 
381
414
  for (const ref of headerFooterRefs) {
382
- if (seenSubPartRelIds.has(ref.relationshipId)) {
415
+ const dedupeKey = `${ref.kind}:${ref.variant}:${ref.relationshipId}`;
416
+ if (seenSubPartKeys.has(dedupeKey)) {
383
417
  continue;
384
418
  }
385
- seenSubPartRelIds.add(ref.relationshipId);
419
+ seenSubPartKeys.add(dedupeKey);
386
420
 
387
421
  const relationship = documentPart.relationships.find(
388
422
  (r) => r.id === ref.relationshipId && r.targetMode === "internal",
@@ -404,6 +438,7 @@ export function loadDocxEditorSession(
404
438
  variant: ref.variant,
405
439
  partPath,
406
440
  relationshipId: ref.relationshipId,
441
+ ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
407
442
  blocks: parsed.blocks,
408
443
  });
409
444
  sourceHeaderPaths.push({ partPath, relationshipId: ref.relationshipId });
@@ -413,6 +448,7 @@ export function loadDocxEditorSession(
413
448
  variant: ref.variant,
414
449
  partPath,
415
450
  relationshipId: ref.relationshipId,
451
+ ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
416
452
  blocks: parsed.blocks,
417
453
  });
418
454
  sourceFooterPaths.push({ partPath, relationshipId: ref.relationshipId });
@@ -466,17 +502,60 @@ export function loadDocxEditorSession(
466
502
  decodeUtf8(sourcePackage.parts.get(themePartPath)?.bytes ?? new Uint8Array()),
467
503
  )
468
504
  : undefined;
505
+ const resolvedTheme = parsedTheme ? resolveTheme(parsedTheme) : undefined;
506
+ const settingsPartPath = resolveDocumentRelatedPartPath(
507
+ sourcePackage,
508
+ mainDocumentPath,
509
+ documentPart.relationships,
510
+ SETTINGS_RELATIONSHIP_TYPE,
511
+ SETTINGS_PART_PATH,
512
+ );
513
+ const parsedSettings =
514
+ settingsPartPath && sourcePackage.parts.has(settingsPartPath)
515
+ ? parseSettingsXml(
516
+ decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array()),
517
+ )
518
+ : undefined;
519
+ const settingsXmlForProtection =
520
+ settingsPartPath && sourcePackage.parts.has(settingsPartPath)
521
+ ? decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array())
522
+ : "";
523
+ const documentProtection = extractDocumentProtection(settingsXmlForProtection);
524
+ const importedProtectionSnapshot = buildProtectionSnapshot(documentProtection, protectionRanges);
525
+
526
+ // ---- Parse styles.xml for canonical style catalog ----
527
+ const stylesPartPath = resolveDocumentRelatedPartPath(
528
+ sourcePackage,
529
+ mainDocumentPath,
530
+ documentPart.relationships,
531
+ STYLES_RELATIONSHIP_TYPE,
532
+ STYLES_PART_PATH,
533
+ );
534
+ const parsedStyles =
535
+ stylesPartPath && sourcePackage.parts.has(stylesPartPath)
536
+ ? parseStylesXml(
537
+ decodeUtf8(sourcePackage.parts.get(stylesPartPath)?.bytes ?? new Uint8Array()),
538
+ )
539
+ : parseStylesXml("");
469
540
 
470
541
  const subParts: SubPartsCatalog | undefined =
471
542
  parsedHeaders.length > 0 ||
472
543
  parsedFooters.length > 0 ||
473
544
  footnoteCollection !== undefined ||
474
- parsedTheme !== undefined
545
+ parsedTheme !== undefined ||
546
+ normalizedDocument.finalSectionProperties !== undefined ||
547
+ resolvedTheme !== undefined ||
548
+ parsedSettings !== undefined
475
549
  ? {
476
550
  headers: parsedHeaders,
477
551
  footers: parsedFooters,
478
552
  ...(footnoteCollection !== undefined ? { footnoteCollection } : {}),
479
553
  ...(parsedTheme !== undefined ? { theme: parsedTheme } : {}),
554
+ ...(normalizedDocument.finalSectionProperties !== undefined
555
+ ? { finalSectionProperties: normalizedDocument.finalSectionProperties }
556
+ : {}),
557
+ ...(resolvedTheme !== undefined ? { resolvedTheme } : {}),
558
+ ...(parsedSettings !== undefined ? { settings: parsedSettings } : {}),
480
559
  }
481
560
  : undefined;
482
561
 
@@ -488,6 +567,7 @@ export function loadDocxEditorSession(
488
567
  media: normalizedDocument.media,
489
568
  content: normalizedDocument.content,
490
569
  subParts,
570
+ parsedStyles,
491
571
  preservation: {
492
572
  ...normalizedDocument.preservation,
493
573
  packageParts: {
@@ -532,12 +612,29 @@ export function loadDocxEditorSession(
532
612
  });
533
613
  const snapshot = createImportedSnapshot({
534
614
  documentId: options.documentId,
535
- editorBuild: options.editorBuild,
615
+ editorBuild,
536
616
  timestamp,
537
617
  document,
538
618
  compatibility: toPublicCompatibilityReport(compatibility),
619
+ protectionSnapshot: importedProtectionSnapshot,
539
620
  sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
540
621
  });
622
+ const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
623
+ if (snapshotIssues.length > 0) {
624
+ const firstIssue = snapshotIssues[0];
625
+ return createDiagnosticsSession(
626
+ options,
627
+ createValidationImportDiagnostics({
628
+ message: `DOCX import produced an invalid editor state during validation${firstIssue ? ` (${firstIssue.path}: ${firstIssue.message})` : "."}`,
629
+ source: "import",
630
+ details: {
631
+ issueCount: snapshotIssues.length,
632
+ firstIssuePath: firstIssue?.path,
633
+ },
634
+ }),
635
+ );
636
+ }
637
+ const initialSessionState = editorSessionStateFromPersistedSnapshot(snapshot);
541
638
  const importedState: ImportedDocxState = {
542
639
  sourceBytes: new Uint8Array(sourceBytes),
543
640
  sourcePackage,
@@ -579,6 +676,7 @@ export function loadDocxEditorSession(
579
676
  )?.id,
580
677
  sourcePeopleRootTag: normalizedComments.sourcePeopleRootTag,
581
678
  sourcePeopleAuthors: normalizedComments.peopleAuthors,
679
+ protectionSnapshot: buildProtectionSnapshot(documentProtection, protectionRanges),
582
680
  preservedCommentDefinitions: normalizedComments.preservedDefinitions,
583
681
  blockingCommentDiagnostics: normalizedComments.diagnostics.filter((diagnostic) =>
584
682
  BLOCKING_COMMENT_DIAGNOSTIC_CODES.has(diagnostic.code),
@@ -597,10 +695,12 @@ export function loadDocxEditorSession(
597
695
  };
598
696
 
599
697
  return {
698
+ initialSessionState,
600
699
  initialSnapshot: snapshot,
601
700
  readOnly: false,
602
- exportDocx: async (nextSnapshot, exportOptions) =>
603
- exportDocxEditorSession(importedState, nextSnapshot, exportOptions),
701
+ protectionSnapshot: importedProtectionSnapshot,
702
+ exportDocx: async (nextSessionState, exportOptions) =>
703
+ exportDocxEditorSession(importedState, nextSessionState, exportOptions),
604
704
  };
605
705
  } catch (error) {
606
706
  return createDiagnosticsSession(
@@ -612,28 +712,41 @@ export function loadDocxEditorSession(
612
712
 
613
713
  function exportDocxEditorSession(
614
714
  state: ImportedDocxState,
615
- snapshot: PersistedEditorSnapshot,
715
+ sessionStateOrSnapshot: EditorSessionState | PersistedEditorSnapshot,
616
716
  options?: ExportDocxOptions,
617
717
  ): ExportResult {
618
- if (snapshot.compatibility.blockExport) {
718
+ const sessionState = toEditorSessionState(sessionStateOrSnapshot);
719
+
720
+ if (sessionState.compatibility.blockExport) {
619
721
  throw new Error("DOCX export is blocked by the current compatibility report.");
620
722
  }
621
723
 
622
- const currentDocument = snapshot.canonicalDocument as CanonicalDocumentEnvelope;
623
- if (
624
- serializeCanonicalDocumentForExport(currentDocument) ===
625
- state.initialCanonicalSignature &&
626
- canReuseSourceBytesForCurrentDocument(state, currentDocument)
627
- ) {
724
+ const currentDocument = sessionState.canonicalDocument as CanonicalDocumentEnvelope;
725
+ const signatureMatch = serializeCanonicalDocumentForExport(currentDocument) ===
726
+ state.initialCanonicalSignature;
727
+ const canReuse = canReuseSourceBytesForCurrentDocument(state, currentDocument);
728
+ const commentCount = Object.keys(currentDocument.review?.comments ?? {}).length;
729
+ console.error(`[DEBUG-EXPORT] docId=${sessionState.documentId} signatureMatch=${signatureMatch} canReuse=${canReuse} preservedDefs=${state.preservedCommentDefinitions.length} blockingDiags=${state.blockingCommentDiagnostics.length} comments=${commentCount}`);
730
+ if (signatureMatch && canReuse) {
628
731
  return {
629
732
  bytes: new Uint8Array(state.sourceBytes),
630
733
  mimeType: DOCX_MIME_TYPE,
631
- fileName: options?.fileName ?? `${snapshot.documentId}.docx`,
734
+ fileName: options?.fileName ?? `${sessionState.documentId}.docx`,
735
+ delivery: {
736
+ mode: "exported-bytes-only",
737
+ },
632
738
  };
633
739
  }
634
- if (state.blockingCommentDiagnostics.length > 0) {
740
+ const preservedCommentIds = new Set(
741
+ state.preservedCommentDefinitions.map((definition) => definition.commentId),
742
+ );
743
+ const blockingCommentCount = Math.max(
744
+ state.blockingCommentDiagnostics.length,
745
+ preservedCommentIds.size,
746
+ );
747
+ if (blockingCommentCount > 0) {
635
748
  throw new Error(
636
- `DOCX export is blocked because ${state.blockingCommentDiagnostics.length} preserve-only comment anchors cannot be safely remapped after runtime edits.`,
749
+ `DOCX export is blocked because ${blockingCommentCount} preserve-only comment anchors cannot be safely remapped after runtime edits.`,
637
750
  );
638
751
  }
639
752
  const currentRevisions = toReviewRevisionRecords(currentDocument.review.revisions);
@@ -643,9 +756,6 @@ function exportDocxEditorSession(
643
756
  const commentThreads = Object.values(
644
757
  createCommentStoreFromRuntimeComments(currentDocument.review.comments).threads,
645
758
  );
646
- const preservedCommentIds = new Set(
647
- state.preservedCommentDefinitions.map((definition) => definition.commentId),
648
- );
649
759
  const ownedCommentThreads = commentThreads.filter(
650
760
  (thread) => !preservedCommentIds.has(thread.commentId),
651
761
  );
@@ -660,6 +770,7 @@ function exportDocxEditorSession(
660
770
  {
661
771
  documentAttributes: state.sourceDocumentAttributes,
662
772
  media: currentDocument.media as MediaCatalog,
773
+ finalSectionProperties: currentDocument.subParts?.finalSectionProperties,
663
774
  },
664
775
  );
665
776
  const revisionDocument = serializeRuntimeRevisionsIntoDocumentXml(
@@ -698,6 +809,10 @@ function exportDocxEditorSession(
698
809
  exportCommentIds,
699
810
  },
700
811
  );
812
+ const protectedDocumentXml = serializeProtectionRangesIntoDocumentXml(
813
+ annotatedDocument.documentXml,
814
+ state.protectionSnapshot,
815
+ );
701
816
  const blockingSkippedCommentIds = annotatedDocument.skippedCommentIds.filter((commentId) => {
702
817
  const thread = ownedCommentThreads.find((candidate) => candidate.commentId === commentId);
703
818
  return !thread || thread.anchor.kind !== "detached";
@@ -717,7 +832,9 @@ function exportDocxEditorSession(
717
832
  state.sourcePeoplePartPath ?? PEOPLE_PART_PATH;
718
833
  const numberingPartPath =
719
834
  state.sourceNumberingPartPath ?? NUMBERING_PART_PATH;
720
- const serializedNumberingXml = hasNumberingEntries(currentDocument.numbering as NumberingCatalog)
835
+ const serializedNumberingXml = hasSerializableNumberingEntries(
836
+ currentDocument.numbering as NumberingCatalog,
837
+ )
721
838
  ? serializeNumberingXml(currentDocument.numbering as NumberingCatalog)
722
839
  : undefined;
723
840
  const nextRelationships = withDocumentRelatedParts(
@@ -802,7 +919,7 @@ function exportDocxEditorSession(
802
919
 
803
920
  exportSession.replaceOwnedPart({
804
921
  path: state.sourceDocumentPartPath,
805
- bytes: new TextEncoder().encode(annotatedDocument.documentXml),
922
+ bytes: new TextEncoder().encode(protectedDocumentXml),
806
923
  contentType: MAIN_DOCUMENT_CONTENT_TYPE,
807
924
  relationships: nextRelationships,
808
925
  });
@@ -923,10 +1040,21 @@ function exportDocxEditorSession(
923
1040
  return {
924
1041
  bytes: exportSession.serialize(),
925
1042
  mimeType: DOCX_MIME_TYPE,
926
- fileName: options?.fileName ?? `${snapshot.documentId}.docx`,
1043
+ fileName: options?.fileName ?? `${sessionState.documentId}.docx`,
1044
+ delivery: {
1045
+ mode: "exported-bytes-only",
1046
+ },
927
1047
  };
928
1048
  }
929
1049
 
1050
+ function toEditorSessionState(
1051
+ sessionStateOrSnapshot: EditorSessionState | PersistedEditorSnapshot,
1052
+ ): EditorSessionState {
1053
+ return "sessionVersion" in sessionStateOrSnapshot
1054
+ ? sessionStateOrSnapshot
1055
+ : editorSessionStateFromPersistedSnapshot(sessionStateOrSnapshot);
1056
+ }
1057
+
930
1058
  function createImportedCanonicalDocument(input: {
931
1059
  documentId: string;
932
1060
  timestamp: string;
@@ -934,23 +1062,21 @@ function createImportedCanonicalDocument(input: {
934
1062
  media: CanonicalDocumentEnvelope["media"];
935
1063
  content: CanonicalDocumentEnvelope["content"];
936
1064
  subParts?: SubPartsCatalog;
1065
+ parsedStyles?: ParseStylesResult;
937
1066
  preservation: CanonicalDocumentEnvelope["preservation"];
938
1067
  diagnostics: CanonicalDocumentEnvelope["diagnostics"];
939
1068
  review: CanonicalDocumentEnvelope["review"];
940
1069
  }): CanonicalDocumentEnvelope {
941
- const paragraphStyles = Object.fromEntries(
942
- [...collectReferencedParagraphStyleIds(input.content, input.subParts)]
943
- .sort((left, right) => left.localeCompare(right))
944
- .map((styleId) => [
945
- styleId,
946
- {
947
- styleId,
948
- displayName: styleId,
949
- kind: "paragraph" as const,
950
- isDefault: styleId === "Normal",
951
- },
952
- ]),
1070
+ const numbering = ensureImportedNumberingCatalogSupportsContent(
1071
+ input.numbering,
1072
+ input.content,
953
1073
  );
1074
+
1075
+ // Use package-backed style catalog when available; fall back to synthetic
1076
+ // styles derived from referenced styleId values when styles.xml is missing
1077
+ // or could not be parsed.
1078
+ const styles = buildStylesCatalog(input.parsedStyles, input.content, input.subParts);
1079
+
954
1080
  return {
955
1081
  schemaVersion: "cds/1.0.0",
956
1082
  docId: createCanonicalDocumentId(input.documentId),
@@ -959,12 +1085,8 @@ function createImportedCanonicalDocument(input: {
959
1085
  metadata: {
960
1086
  customProperties: {},
961
1087
  },
962
- styles: {
963
- paragraphs: paragraphStyles,
964
- characters: {},
965
- tables: {},
966
- },
967
- numbering: input.numbering,
1088
+ styles,
1089
+ numbering,
968
1090
  media: input.media,
969
1091
  content: input.content,
970
1092
  review: input.review,
@@ -974,6 +1096,136 @@ function createImportedCanonicalDocument(input: {
974
1096
  };
975
1097
  }
976
1098
 
1099
+ // Canonical model styleId validation pattern — styleIds that don't match
1100
+ // are excluded from the catalog to avoid snapshot validation failures.
1101
+ const VALID_STYLE_ID = /^[A-Za-z_][A-Za-z0-9._-]{0,127}$/;
1102
+
1103
+ function buildStylesCatalog(
1104
+ parsedStyles: ParseStylesResult | undefined,
1105
+ content: CanonicalDocumentEnvelope["content"],
1106
+ subParts?: SubPartsCatalog,
1107
+ ): CanonicalDocumentEnvelope["styles"] {
1108
+ if (parsedStyles?.fromPackage) {
1109
+ // Package-backed catalog: filter entries whose styleId does not satisfy
1110
+ // the canonical model pattern (e.g. numeric-only ids from Word).
1111
+ const catalog = filterValidStyleIds(parsedStyles.catalog);
1112
+
1113
+ // Merge in any referenced styleIds that the package styles.xml did not
1114
+ // define (rare but defensive).
1115
+ const referencedIds = collectReferencedParagraphStyleIds(content, subParts);
1116
+ for (const styleId of referencedIds) {
1117
+ if (!catalog.paragraphs[styleId] && VALID_STYLE_ID.test(styleId)) {
1118
+ catalog.paragraphs[styleId] = {
1119
+ styleId,
1120
+ displayName: styleId,
1121
+ kind: "paragraph",
1122
+ isDefault: styleId === "Normal",
1123
+ };
1124
+ }
1125
+ }
1126
+ return {
1127
+ ...catalog,
1128
+ fromPackage: true,
1129
+ };
1130
+ }
1131
+
1132
+ // Synthetic fallback: no styles.xml available
1133
+ const paragraphStyles = Object.fromEntries(
1134
+ [...collectReferencedParagraphStyleIds(content, subParts)]
1135
+ .sort((left, right) => left.localeCompare(right))
1136
+ .filter((styleId) => VALID_STYLE_ID.test(styleId))
1137
+ .map((styleId) => [
1138
+ styleId,
1139
+ {
1140
+ styleId,
1141
+ displayName: styleId,
1142
+ kind: "paragraph" as const,
1143
+ isDefault: styleId === "Normal",
1144
+ },
1145
+ ]),
1146
+ );
1147
+ return {
1148
+ paragraphs: paragraphStyles,
1149
+ characters: {},
1150
+ tables: {},
1151
+ fromPackage: false,
1152
+ };
1153
+ }
1154
+
1155
+ function filterValidStyleIds(
1156
+ catalog: CanonicalDocumentEnvelope["styles"],
1157
+ ): CanonicalDocumentEnvelope["styles"] {
1158
+ const filterRecord = <T extends { styleId: string }>(
1159
+ record: Record<string, T>,
1160
+ ): Record<string, T> => {
1161
+ const result: Record<string, T> = {};
1162
+ for (const [key, value] of Object.entries(record)) {
1163
+ if (VALID_STYLE_ID.test(key)) {
1164
+ result[key] = value;
1165
+ }
1166
+ }
1167
+ return result;
1168
+ };
1169
+
1170
+ return {
1171
+ paragraphs: filterRecord(catalog.paragraphs),
1172
+ characters: filterRecord(catalog.characters),
1173
+ tables: filterRecord(catalog.tables),
1174
+ ...(catalog.latentStyles ? { latentStyles: catalog.latentStyles } : {}),
1175
+ ...(catalog.fromPackage !== undefined ? { fromPackage: catalog.fromPackage } : {}),
1176
+ };
1177
+ }
1178
+
1179
+ function ensureImportedNumberingCatalogSupportsContent(
1180
+ catalog: NumberingCatalog,
1181
+ content: CanonicalDocumentEnvelope["content"],
1182
+ ): NumberingCatalog {
1183
+ if (
1184
+ catalog.instances[DOCX_NULL_NUMBERING_INSTANCE_ID] ||
1185
+ !collectReferencedNumberingInstanceIds(content).has(DOCX_NULL_NUMBERING_INSTANCE_ID)
1186
+ ) {
1187
+ return catalog;
1188
+ }
1189
+
1190
+ const syntheticNullCatalog = createSyntheticDocxNullNumberingCatalog();
1191
+ return {
1192
+ abstractDefinitions: {
1193
+ ...catalog.abstractDefinitions,
1194
+ ...syntheticNullCatalog.abstractDefinitions,
1195
+ },
1196
+ instances: {
1197
+ ...catalog.instances,
1198
+ ...syntheticNullCatalog.instances,
1199
+ },
1200
+ };
1201
+ }
1202
+
1203
+ function collectReferencedNumberingInstanceIds(
1204
+ content: CanonicalDocumentEnvelope["content"],
1205
+ ): Set<string> {
1206
+ const numberingInstanceIds = new Set<string>();
1207
+
1208
+ const visitBlocks = (blocks: ReadonlyArray<BlockNode>) => {
1209
+ for (const block of blocks) {
1210
+ if (block.type === "paragraph" && block.numbering?.numberingInstanceId) {
1211
+ numberingInstanceIds.add(block.numbering.numberingInstanceId);
1212
+ }
1213
+ if (block.type === "table") {
1214
+ for (const row of block.rows) {
1215
+ for (const cell of row.cells) {
1216
+ visitBlocks(cell.children);
1217
+ }
1218
+ }
1219
+ } else if (block.type === "sdt" || block.type === "custom_xml") {
1220
+ visitBlocks(block.children);
1221
+ }
1222
+ }
1223
+ };
1224
+
1225
+ visitBlocks(content.children);
1226
+ return numberingInstanceIds;
1227
+ }
1228
+
977
1229
  function collectReferencedParagraphStyleIds(
978
1230
  content: CanonicalDocumentEnvelope["content"],
979
1231
  subParts?: SubPartsCatalog,
@@ -1024,6 +1276,7 @@ function createImportedSnapshot(input: {
1024
1276
  timestamp: string;
1025
1277
  document: CanonicalDocumentEnvelope;
1026
1278
  compatibility: PersistedEditorSnapshot["compatibility"];
1279
+ protectionSnapshot: ProtectionSnapshot;
1027
1280
  sourcePackage?: PersistedEditorSnapshot["sourcePackage"];
1028
1281
  }): PersistedEditorSnapshot {
1029
1282
  return {
@@ -1038,6 +1291,7 @@ function createImportedSnapshot(input: {
1038
1291
  canonicalDocument: input.document,
1039
1292
  compatibility: input.compatibility,
1040
1293
  warningLog: input.compatibility.warnings,
1294
+ protectionSnapshot: input.protectionSnapshot,
1041
1295
  sourcePackage: input.sourcePackage,
1042
1296
  };
1043
1297
  }
@@ -1110,20 +1364,27 @@ function createDiagnosticsSession(
1110
1364
  diagnostics: ImportDiagnosticsResult,
1111
1365
  ): LoadedDocxEditorSession {
1112
1366
  const timestamp = new Date().toISOString();
1367
+ const editorBuild =
1368
+ typeof options.editorBuild === "string" && options.editorBuild.length > 0
1369
+ ? options.editorBuild
1370
+ : "dev";
1113
1371
  const runtime = createReadOnlyDiagnosticsRuntime({
1114
1372
  documentId: options.documentId,
1115
1373
  sourceLabel: options.sourceLabel,
1116
- editorBuild: options.editorBuild,
1374
+ editorBuild,
1117
1375
  generatedAt: timestamp,
1118
1376
  diagnostics,
1119
1377
  });
1120
1378
  const initialSnapshot = runtime.getPersistedSnapshot();
1379
+ const initialSessionState = editorSessionStateFromPersistedSnapshot(initialSnapshot);
1121
1380
 
1122
1381
  return {
1382
+ initialSessionState,
1123
1383
  initialSnapshot,
1124
1384
  fatalError: diagnostics.fatalError,
1125
1385
  readOnly: true,
1126
- exportDocx: async (_snapshot, exportOptions) => runtime.exportDocx(exportOptions),
1386
+ protectionSnapshot: EMPTY_PROTECTION_SNAPSHOT,
1387
+ exportDocx: async (_sessionState, exportOptions) => runtime.exportDocx(exportOptions),
1127
1388
  };
1128
1389
  }
1129
1390
 
@@ -1238,8 +1499,10 @@ function normalizeImportedCommentThreads(
1238
1499
  commentId: thread.commentId,
1239
1500
  code: "opaque_anchor_preserve_only",
1240
1501
  message:
1241
- "Comment anchor intersects preserve-only OOXML and remains preserve-only on export.",
1502
+ "Comment anchor intersects preserve-only OOXML content. Thread is visible but detached; anchor cannot be safely remapped.",
1242
1503
  featureClass: "preserve-only",
1504
+ detachedReason: "opaque-region" as const,
1505
+ actionabilityNote: "The comment body is preserved. The anchor overlaps opaque content that the editor cannot safely modify.",
1243
1506
  });
1244
1507
  return {
1245
1508
  ...thread,
@@ -1257,8 +1520,10 @@ function normalizeImportedCommentThreads(
1257
1520
  commentId: thread.commentId,
1258
1521
  code: "preserve_only_revision_overlap",
1259
1522
  message:
1260
- "Comment anchor overlaps preserve-only review markup and remains preserve-only on export.",
1523
+ "Comment anchor overlaps preserve-only review markup. Thread is visible but detached; anchor cannot be safely remapped during editing.",
1261
1524
  featureClass: "preserve-only",
1525
+ detachedReason: "revision-overlap" as const,
1526
+ actionabilityNote: "The comment body is preserved. The anchor overlaps preserve-only revision markup that the editor cannot safely modify.",
1262
1527
  });
1263
1528
  return {
1264
1529
  ...thread,
@@ -1670,13 +1935,6 @@ function createEmptyNumberingCatalog(): NumberingCatalog {
1670
1935
  };
1671
1936
  }
1672
1937
 
1673
- function hasNumberingEntries(catalog: NumberingCatalog): boolean {
1674
- return (
1675
- Object.keys(catalog.abstractDefinitions ?? {}).length > 0 ||
1676
- Object.keys(catalog.instances ?? {}).length > 0
1677
- );
1678
- }
1679
-
1680
1938
  function collectBrokenInternalRelationshipIssues(
1681
1939
  sourcePackage: OpcPackage,
1682
1940
  mainDocumentPath?: string,
@@ -2126,6 +2384,108 @@ function xmlNode(tagName: string, value: string | undefined): string | undefined
2126
2384
  return `<${tagName}>${escapeXml(value)}</${tagName.split(" ", 1)[0]}>`;
2127
2385
  }
2128
2386
 
2387
+ function serializeProtectionRangesIntoDocumentXml(
2388
+ documentXml: string,
2389
+ protection: ProtectionSnapshot,
2390
+ paragraphs = mapParagraphBoundaries(documentXml),
2391
+ ): string {
2392
+ if (protection.ranges.length === 0) {
2393
+ return documentXml;
2394
+ }
2395
+
2396
+ const insertions = new Map<number, string[]>();
2397
+
2398
+ for (const range of protection.ranges) {
2399
+ if (typeof range.start !== "number" || typeof range.end !== "number") {
2400
+ continue;
2401
+ }
2402
+ const rangeStart = range.start;
2403
+ const rangeEnd = range.end;
2404
+
2405
+ const startParagraph = paragraphs.find(
2406
+ (candidate) => rangeStart >= candidate.start && rangeStart <= candidate.end,
2407
+ );
2408
+ const endParagraph = paragraphs.find(
2409
+ (candidate) => rangeEnd >= candidate.start && rangeEnd <= candidate.end,
2410
+ );
2411
+ if (!startParagraph || !endParagraph) {
2412
+ continue;
2413
+ }
2414
+
2415
+ const startIndex =
2416
+ startParagraph.boundaries.get(rangeStart) ??
2417
+ findNearestBoundaryIndex(startParagraph.boundaries, rangeStart, "backward");
2418
+ const endIndex =
2419
+ endParagraph.boundaries.get(rangeEnd) ??
2420
+ findNearestBoundaryIndex(endParagraph.boundaries, rangeEnd, "forward");
2421
+ if (startIndex === undefined || endIndex === undefined) {
2422
+ continue;
2423
+ }
2424
+
2425
+ const permStartXml = [
2426
+ `<w:permStart`,
2427
+ ` w:id="${escapeXmlAttribute(range.rangeId)}"`,
2428
+ range.editorGroup ? ` w:edGrp="${escapeXmlAttribute(range.editorGroup)}"` : "",
2429
+ range.editor ? ` w:ed="${escapeXmlAttribute(range.editor)}"` : "",
2430
+ `/>`,
2431
+ ].join("");
2432
+ const permEndXml = `<w:permEnd w:id="${escapeXmlAttribute(range.rangeId)}"/>`;
2433
+
2434
+ pushProtectionInsertion(insertions, startIndex, permStartXml);
2435
+ pushProtectionInsertion(insertions, endIndex, permEndXml);
2436
+ }
2437
+
2438
+ if (insertions.size === 0) {
2439
+ return documentXml;
2440
+ }
2441
+
2442
+ const parts: string[] = [];
2443
+ let cursor = 0;
2444
+ for (const [index, snippets] of [...insertions.entries()].sort(([left], [right]) => left - right)) {
2445
+ parts.push(documentXml.slice(cursor, index));
2446
+ parts.push(...snippets);
2447
+ cursor = index;
2448
+ }
2449
+ parts.push(documentXml.slice(cursor));
2450
+ return parts.join("");
2451
+ }
2452
+
2453
+ function pushProtectionInsertion(
2454
+ insertions: Map<number, string[]>,
2455
+ index: number,
2456
+ xml: string,
2457
+ ): void {
2458
+ const existing = insertions.get(index);
2459
+ if (existing) {
2460
+ existing.push(xml);
2461
+ return;
2462
+ }
2463
+ insertions.set(index, [xml]);
2464
+ }
2465
+
2466
+ function findNearestBoundaryIndex(
2467
+ boundaries: Map<number, number>,
2468
+ position: number,
2469
+ direction: "backward" | "forward",
2470
+ ): number | undefined {
2471
+ const ordered = [...boundaries.entries()].sort(([left], [right]) => left - right);
2472
+ if (direction === "backward") {
2473
+ for (let index = ordered.length - 1; index >= 0; index -= 1) {
2474
+ const [boundaryPos, boundaryIndex] = ordered[index]!;
2475
+ if (boundaryPos <= position) {
2476
+ return boundaryIndex;
2477
+ }
2478
+ }
2479
+ return undefined;
2480
+ }
2481
+ for (const [boundaryPos, boundaryIndex] of ordered) {
2482
+ if (boundaryPos >= position) {
2483
+ return boundaryIndex;
2484
+ }
2485
+ }
2486
+ return undefined;
2487
+ }
2488
+
2129
2489
  function escapeXml(value: string): string {
2130
2490
  return value
2131
2491
  .replace(/&/g, "&amp;")
@@ -2134,3 +2494,210 @@ function escapeXml(value: string): string {
2134
2494
  .replace(/\"/g, "&quot;")
2135
2495
  .replace(/'/g, "&apos;");
2136
2496
  }
2497
+
2498
+ function escapeXmlAttribute(value: string): string {
2499
+ return value
2500
+ .replace(/&/g, "&amp;")
2501
+ .replace(/</g, "&lt;")
2502
+ .replace(/>/g, "&gt;")
2503
+ .replace(/"/g, "&quot;");
2504
+ }
2505
+
2506
+ // ---------------------------------------------------------------------------
2507
+ // Protection range extraction
2508
+ // ---------------------------------------------------------------------------
2509
+
2510
+ const EMPTY_PROTECTION_SNAPSHOT: ProtectionSnapshot = {
2511
+ hasDocumentProtection: false,
2512
+ enforcementActive: false,
2513
+ ranges: [],
2514
+ enforcedRangeCount: 0,
2515
+ preservedRangeCount: 0,
2516
+ };
2517
+
2518
+ interface DocumentProtectionMeta {
2519
+ editType?: string;
2520
+ enforcement: boolean;
2521
+ }
2522
+
2523
+ function extractDocumentProtection(settingsXml: string): DocumentProtectionMeta {
2524
+ if (!settingsXml) return { enforcement: false };
2525
+ const match = settingsXml.match(/<w:documentProtection\b([^/>]*)\/?>/);
2526
+ if (!match) return { enforcement: false };
2527
+ const attrs = match[1];
2528
+ const editTypeMatch = attrs.match(/w:edit="([^"]*)"/);
2529
+ const enforcementMatch = attrs.match(/w:enforcement="([^"]*)"/);
2530
+ const editType = editTypeMatch?.[1];
2531
+ const enforcement = enforcementMatch?.[1] === "1" || enforcementMatch?.[1] === "true";
2532
+ return { editType, enforcement };
2533
+ }
2534
+
2535
+ function extractProtectionRanges(blocks: readonly ParsedBlockNode[]): ProtectionRange[] {
2536
+ const ranges: ProtectionRange[] = [];
2537
+ const openRanges = new Map<string, Omit<ProtectionRange, "end">>();
2538
+ collectProtectionRangesFromBlocks(blocks, ranges, openRanges, 0);
2539
+ return ranges;
2540
+ }
2541
+
2542
+ function collectProtectionRangesFromBlocks(
2543
+ blocks: readonly ParsedBlockNode[],
2544
+ ranges: ProtectionRange[],
2545
+ openRanges: Map<string, Omit<ProtectionRange, "end">>,
2546
+ cursor: number,
2547
+ ): number {
2548
+ let nextCursor = cursor;
2549
+ let previousParagraph = false;
2550
+
2551
+ for (const block of blocks) {
2552
+ if (block.type === "paragraph") {
2553
+ if (previousParagraph) {
2554
+ nextCursor += 1;
2555
+ }
2556
+ nextCursor = collectProtectionRangesFromInlines(
2557
+ block.children,
2558
+ ranges,
2559
+ openRanges,
2560
+ nextCursor,
2561
+ );
2562
+ previousParagraph = true;
2563
+ continue;
2564
+ }
2565
+
2566
+ if (block.type === "table") {
2567
+ nextCursor += 1;
2568
+ previousParagraph = false;
2569
+ for (const row of block.rows) {
2570
+ for (const cell of row.cells) {
2571
+ nextCursor = collectProtectionRangesFromBlocks(
2572
+ cell.children,
2573
+ ranges,
2574
+ openRanges,
2575
+ nextCursor,
2576
+ );
2577
+ }
2578
+ }
2579
+ continue;
2580
+ }
2581
+
2582
+ if (block.type === "sdt" || block.type === "custom_xml") {
2583
+ nextCursor = collectProtectionRangesFromBlocks(
2584
+ block.children,
2585
+ ranges,
2586
+ openRanges,
2587
+ nextCursor,
2588
+ );
2589
+ previousParagraph = false;
2590
+ continue;
2591
+ }
2592
+
2593
+ nextCursor += 1;
2594
+ previousParagraph = false;
2595
+ }
2596
+
2597
+ return nextCursor;
2598
+ }
2599
+
2600
+ function collectProtectionRangesFromInlines(
2601
+ nodes: readonly ParsedInlineNode[],
2602
+ ranges: ProtectionRange[],
2603
+ openRanges: Map<string, Omit<ProtectionRange, "end">>,
2604
+ cursor: number,
2605
+ ): number {
2606
+ let nextCursor = cursor;
2607
+
2608
+ for (const node of nodes) {
2609
+ if (node.type === "perm_start") {
2610
+ openRanges.set(node.rangeId, {
2611
+ rangeId: node.rangeId,
2612
+ start: nextCursor,
2613
+ ...(node.editorGroup ? { editorGroup: node.editorGroup } : {}),
2614
+ ...(node.editor ? { editor: node.editor } : {}),
2615
+ enforced: false,
2616
+ enforcementReason:
2617
+ "preserve-only: runtime does not yet enforce permission range boundaries",
2618
+ });
2619
+ continue;
2620
+ }
2621
+
2622
+ if (node.type === "perm_end") {
2623
+ const openRange = openRanges.get(node.rangeId);
2624
+ if (openRange) {
2625
+ ranges.push({
2626
+ ...openRange,
2627
+ end: nextCursor,
2628
+ });
2629
+ openRanges.delete(node.rangeId);
2630
+ }
2631
+ continue;
2632
+ }
2633
+
2634
+ nextCursor += measureParsedInlineNode(node);
2635
+ }
2636
+
2637
+ return nextCursor;
2638
+ }
2639
+
2640
+ function measureParsedInlineNode(node: ParsedInlineNode): number {
2641
+ switch (node.type) {
2642
+ case "text":
2643
+ return node.text.length;
2644
+ case "tab":
2645
+ case "hard_break":
2646
+ case "column_break":
2647
+ case "footnote_ref":
2648
+ case "image":
2649
+ case "bookmark_start":
2650
+ case "bookmark_end":
2651
+ return 1;
2652
+ case "hyperlink":
2653
+ return node.children.reduce((size, child) => size + measureParsedInlineNode(child), 0);
2654
+ case "field": {
2655
+ const content = parseMainDocumentXml(
2656
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>${node.contentXml}</w:p></w:body></w:document>`,
2657
+ );
2658
+ if (content.blocks[0]?.type === "paragraph") {
2659
+ return content.blocks[0].children.reduce(
2660
+ (size, child) => size + measureParsedInlineNode(child),
2661
+ 0,
2662
+ );
2663
+ }
2664
+ return 1;
2665
+ }
2666
+ default:
2667
+ return 1;
2668
+ }
2669
+ }
2670
+
2671
+ function buildProtectionSnapshot(
2672
+ documentProtection: DocumentProtectionMeta,
2673
+ ranges: ProtectionRange[],
2674
+ ): ProtectionSnapshot {
2675
+ const hasDocumentProtection =
2676
+ documentProtection.editType !== undefined || documentProtection.enforcement;
2677
+ const enforceRanges =
2678
+ documentProtection.editType === "readOnly" || documentProtection.editType === "comments";
2679
+ const normalizedRanges = ranges.map((range) => {
2680
+ const canEnforce =
2681
+ hasDocumentProtection &&
2682
+ documentProtection.enforcement &&
2683
+ enforceRanges &&
2684
+ typeof range.start === "number" &&
2685
+ typeof range.end === "number" &&
2686
+ range.end >= range.start;
2687
+ return {
2688
+ ...range,
2689
+ enforced: canEnforce,
2690
+ enforcementReason: canEnforce
2691
+ ? "runtime-enforced: permission range is mapped to canonical positions"
2692
+ : "preserve-only: runtime does not yet enforce permission range boundaries",
2693
+ };
2694
+ });
2695
+ return {
2696
+ hasDocumentProtection,
2697
+ editType: documentProtection.editType,
2698
+ enforcementActive: documentProtection.enforcement,
2699
+ ranges: normalizedRanges,
2700
+ enforcedRangeCount: normalizedRanges.filter((r) => r.enforced).length,
2701
+ preservedRangeCount: normalizedRanges.filter((r) => !r.enforced).length,
2702
+ };
2703
+ }