@beyondwork/docx-react-component 1.0.19 → 1.0.21

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 (71) hide show
  1. package/package.json +44 -25
  2. package/src/api/public-types.ts +336 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/formatting-commands.ts +1 -1
  5. package/src/core/commands/index.ts +14 -2
  6. package/src/core/search/search-text.ts +28 -0
  7. package/src/core/state/editor-state.ts +3 -0
  8. package/src/index.ts +21 -0
  9. package/src/io/docx-session.ts +363 -17
  10. package/src/io/export/serialize-comments.ts +104 -34
  11. package/src/io/export/serialize-footnotes.ts +198 -1
  12. package/src/io/export/serialize-headers-footers.ts +203 -10
  13. package/src/io/export/serialize-main-document.ts +83 -3
  14. package/src/io/export/split-review-boundaries.ts +181 -19
  15. package/src/io/normalize/normalize-text.ts +82 -8
  16. package/src/io/ooxml/highlight-colors.ts +39 -0
  17. package/src/io/ooxml/parse-comments.ts +85 -19
  18. package/src/io/ooxml/parse-fields.ts +396 -0
  19. package/src/io/ooxml/parse-footnotes.ts +240 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +431 -7
  21. package/src/io/ooxml/parse-inline-media.ts +15 -1
  22. package/src/io/ooxml/parse-main-document.ts +396 -14
  23. package/src/io/ooxml/parse-revisions.ts +317 -38
  24. package/src/legal/bookmarks.ts +44 -0
  25. package/src/legal/cross-references.ts +59 -1
  26. package/src/model/canonical-document.ts +117 -1
  27. package/src/model/snapshot.ts +85 -1
  28. package/src/review/store/revision-store.ts +6 -0
  29. package/src/review/store/revision-types.ts +1 -0
  30. package/src/runtime/document-navigation.ts +52 -13
  31. package/src/runtime/document-runtime.ts +1521 -75
  32. package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
  33. package/src/runtime/session-capabilities.ts +33 -3
  34. package/src/runtime/surface-projection.ts +86 -25
  35. package/src/runtime/table-schema.ts +2 -2
  36. package/src/runtime/view-state.ts +24 -6
  37. package/src/runtime/workflow-markup.ts +349 -0
  38. package/src/ui/WordReviewEditor.tsx +915 -1314
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1448 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +55 -0
  43. package/src/ui/headless/revision-decoration-model.ts +4 -4
  44. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  45. package/src/ui/workflow-surface-blocked-rails.ts +94 -0
  46. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  47. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  48. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  49. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  52. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  53. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  54. package/src/ui-tailwind/editor-surface/pm-decorations.ts +237 -0
  55. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  56. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  57. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  58. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +55 -0
  59. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +190 -48
  61. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  62. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  63. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  64. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  65. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  66. package/src/ui-tailwind/theme/editor-theme.css +130 -0
  67. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  68. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  69. package/src/validation/compatibility-engine.ts +27 -4
  70. package/src/validation/compatibility-report.ts +1 -0
  71. package/src/validation/docx-comment-proof.ts +220 -0
@@ -7,6 +7,8 @@ import type {
7
7
  ExportDocxOptions,
8
8
  ExportResult,
9
9
  PersistedEditorSnapshot,
10
+ ProtectionRange,
11
+ ProtectionSnapshot,
10
12
  } from "../api/public-types.ts";
11
13
  import { editorSessionStateFromPersistedSnapshot } from "../api/session-state.ts";
12
14
  import type {
@@ -25,7 +27,12 @@ import {
25
27
  } from "../core/selection/mapping.ts";
26
28
  import { DOCX_MIME_TYPE } from "./opc/docx-package.ts";
27
29
  import { readOpcPackage, type OpcPackage } from "./opc/package-reader.ts";
28
- 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";
29
36
  import { normalizeParsedTextDocument } from "./normalize/normalize-text.ts";
30
37
  import {
31
38
  CONTENT_TYPES_PATH,
@@ -47,6 +54,7 @@ import { parseCommentsFromOoxml } from "./ooxml/parse-comments.ts";
47
54
  import { parseNumberingXml } from "./ooxml/parse-numbering.ts";
48
55
  import {
49
56
  createCommentExportIdMap,
57
+ mapParagraphBoundaries,
50
58
  serializeCommentAnchorsIntoDocumentXml,
51
59
  serializeMergedCommentsXml,
52
60
  } from "./export/serialize-comments.ts";
@@ -181,6 +189,7 @@ export interface LoadedDocxEditorSession {
181
189
  initialSnapshot: PersistedEditorSnapshot;
182
190
  fatalError?: EditorError;
183
191
  readOnly: boolean;
192
+ protectionSnapshot: ProtectionSnapshot;
184
193
  exportDocx: (
185
194
  sessionState: EditorSessionState | PersistedEditorSnapshot,
186
195
  options?: ExportDocxOptions,
@@ -208,6 +217,7 @@ interface ImportedDocxState {
208
217
  sourcePeopleRelationshipId?: string;
209
218
  sourcePeopleRootTag?: string;
210
219
  sourcePeopleAuthors: readonly string[];
220
+ protectionSnapshot: ProtectionSnapshot;
211
221
  preservedCommentDefinitions: readonly ImportedCommentDefinition[];
212
222
  blockingCommentDiagnostics: readonly CommentImportDiagnostic[];
213
223
  initialCanonicalSignature: string;
@@ -326,6 +336,7 @@ export function loadDocxEditorSession(
326
336
  mediaParts,
327
337
  mainDocumentPath,
328
338
  );
339
+ const protectionRanges = extractProtectionRanges(parsedDocument.blocks);
329
340
  const normalizedDocument = normalizeParsedTextDocument(
330
341
  parsedDocument,
331
342
  mainDocumentPath,
@@ -398,13 +409,14 @@ export function loadDocxEditorSession(
398
409
  const parsedFooters: FooterDocument[] = [];
399
410
  const sourceHeaderPaths: Array<{ partPath: string; relationshipId: string }> = [];
400
411
  const sourceFooterPaths: Array<{ partPath: string; relationshipId: string }> = [];
401
- const seenSubPartRelIds = new Set<string>();
412
+ const seenSubPartKeys = new Set<string>();
402
413
 
403
414
  for (const ref of headerFooterRefs) {
404
- if (seenSubPartRelIds.has(ref.relationshipId)) {
415
+ const dedupeKey = `${ref.kind}:${ref.variant}:${ref.relationshipId}`;
416
+ if (seenSubPartKeys.has(dedupeKey)) {
405
417
  continue;
406
418
  }
407
- seenSubPartRelIds.add(ref.relationshipId);
419
+ seenSubPartKeys.add(dedupeKey);
408
420
 
409
421
  const relationship = documentPart.relationships.find(
410
422
  (r) => r.id === ref.relationshipId && r.targetMode === "internal",
@@ -504,6 +516,12 @@ export function loadDocxEditorSession(
504
516
  decodeUtf8(sourcePackage.parts.get(settingsPartPath)?.bytes ?? new Uint8Array()),
505
517
  )
506
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);
507
525
 
508
526
  // ---- Parse styles.xml for canonical style catalog ----
509
527
  const stylesPartPath = resolveDocumentRelatedPartPath(
@@ -598,6 +616,7 @@ export function loadDocxEditorSession(
598
616
  timestamp,
599
617
  document,
600
618
  compatibility: toPublicCompatibilityReport(compatibility),
619
+ protectionSnapshot: importedProtectionSnapshot,
601
620
  sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
602
621
  });
603
622
  const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
@@ -657,6 +676,7 @@ export function loadDocxEditorSession(
657
676
  )?.id,
658
677
  sourcePeopleRootTag: normalizedComments.sourcePeopleRootTag,
659
678
  sourcePeopleAuthors: normalizedComments.peopleAuthors,
679
+ protectionSnapshot: buildProtectionSnapshot(documentProtection, protectionRanges),
660
680
  preservedCommentDefinitions: normalizedComments.preservedDefinitions,
661
681
  blockingCommentDiagnostics: normalizedComments.diagnostics.filter((diagnostic) =>
662
682
  BLOCKING_COMMENT_DIAGNOSTIC_CODES.has(diagnostic.code),
@@ -678,6 +698,7 @@ export function loadDocxEditorSession(
678
698
  initialSessionState,
679
699
  initialSnapshot: snapshot,
680
700
  readOnly: false,
701
+ protectionSnapshot: importedProtectionSnapshot,
681
702
  exportDocx: async (nextSessionState, exportOptions) =>
682
703
  exportDocxEditorSession(importedState, nextSessionState, exportOptions),
683
704
  };
@@ -701,11 +722,12 @@ function exportDocxEditorSession(
701
722
  }
702
723
 
703
724
  const currentDocument = sessionState.canonicalDocument as CanonicalDocumentEnvelope;
704
- if (
705
- serializeCanonicalDocumentForExport(currentDocument) ===
706
- state.initialCanonicalSignature &&
707
- canReuseSourceBytesForCurrentDocument(state, currentDocument)
708
- ) {
725
+ const signatureMatch = serializeCanonicalDocumentForExport(currentDocument) ===
726
+ state.initialCanonicalSignature;
727
+ const canReuse = canReuseSourceBytesForCurrentDocument(state, currentDocument);
728
+ const commentCount = Object.keys(currentDocument.review?.comments ?? {}).length;
729
+
730
+ if (signatureMatch && canReuse) {
709
731
  return {
710
732
  bytes: new Uint8Array(state.sourceBytes),
711
733
  mimeType: DOCX_MIME_TYPE,
@@ -715,9 +737,16 @@ function exportDocxEditorSession(
715
737
  },
716
738
  };
717
739
  }
718
- 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) {
719
748
  throw new Error(
720
- `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.`,
721
750
  );
722
751
  }
723
752
  const currentRevisions = toReviewRevisionRecords(currentDocument.review.revisions);
@@ -727,9 +756,6 @@ function exportDocxEditorSession(
727
756
  const commentThreads = Object.values(
728
757
  createCommentStoreFromRuntimeComments(currentDocument.review.comments).threads,
729
758
  );
730
- const preservedCommentIds = new Set(
731
- state.preservedCommentDefinitions.map((definition) => definition.commentId),
732
- );
733
759
  const ownedCommentThreads = commentThreads.filter(
734
760
  (thread) => !preservedCommentIds.has(thread.commentId),
735
761
  );
@@ -783,6 +809,10 @@ function exportDocxEditorSession(
783
809
  exportCommentIds,
784
810
  },
785
811
  );
812
+ const protectedDocumentXml = serializeProtectionRangesIntoDocumentXml(
813
+ annotatedDocument.documentXml,
814
+ state.protectionSnapshot,
815
+ );
786
816
  const blockingSkippedCommentIds = annotatedDocument.skippedCommentIds.filter((commentId) => {
787
817
  const thread = ownedCommentThreads.find((candidate) => candidate.commentId === commentId);
788
818
  return !thread || thread.anchor.kind !== "detached";
@@ -889,7 +919,7 @@ function exportDocxEditorSession(
889
919
 
890
920
  exportSession.replaceOwnedPart({
891
921
  path: state.sourceDocumentPartPath,
892
- bytes: new TextEncoder().encode(annotatedDocument.documentXml),
922
+ bytes: new TextEncoder().encode(protectedDocumentXml),
893
923
  contentType: MAIN_DOCUMENT_CONTENT_TYPE,
894
924
  relationships: nextRelationships,
895
925
  });
@@ -1246,6 +1276,7 @@ function createImportedSnapshot(input: {
1246
1276
  timestamp: string;
1247
1277
  document: CanonicalDocumentEnvelope;
1248
1278
  compatibility: PersistedEditorSnapshot["compatibility"];
1279
+ protectionSnapshot: ProtectionSnapshot;
1249
1280
  sourcePackage?: PersistedEditorSnapshot["sourcePackage"];
1250
1281
  }): PersistedEditorSnapshot {
1251
1282
  return {
@@ -1260,6 +1291,7 @@ function createImportedSnapshot(input: {
1260
1291
  canonicalDocument: input.document,
1261
1292
  compatibility: input.compatibility,
1262
1293
  warningLog: input.compatibility.warnings,
1294
+ protectionSnapshot: input.protectionSnapshot,
1263
1295
  sourcePackage: input.sourcePackage,
1264
1296
  };
1265
1297
  }
@@ -1351,6 +1383,7 @@ function createDiagnosticsSession(
1351
1383
  initialSnapshot,
1352
1384
  fatalError: diagnostics.fatalError,
1353
1385
  readOnly: true,
1386
+ protectionSnapshot: EMPTY_PROTECTION_SNAPSHOT,
1354
1387
  exportDocx: async (_sessionState, exportOptions) => runtime.exportDocx(exportOptions),
1355
1388
  };
1356
1389
  }
@@ -1466,8 +1499,10 @@ function normalizeImportedCommentThreads(
1466
1499
  commentId: thread.commentId,
1467
1500
  code: "opaque_anchor_preserve_only",
1468
1501
  message:
1469
- "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.",
1470
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.",
1471
1506
  });
1472
1507
  return {
1473
1508
  ...thread,
@@ -1485,8 +1520,10 @@ function normalizeImportedCommentThreads(
1485
1520
  commentId: thread.commentId,
1486
1521
  code: "preserve_only_revision_overlap",
1487
1522
  message:
1488
- "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.",
1489
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.",
1490
1527
  });
1491
1528
  return {
1492
1529
  ...thread,
@@ -2347,6 +2384,108 @@ function xmlNode(tagName: string, value: string | undefined): string | undefined
2347
2384
  return `<${tagName}>${escapeXml(value)}</${tagName.split(" ", 1)[0]}>`;
2348
2385
  }
2349
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
+
2350
2489
  function escapeXml(value: string): string {
2351
2490
  return value
2352
2491
  .replace(/&/g, "&amp;")
@@ -2355,3 +2494,210 @@ function escapeXml(value: string): string {
2355
2494
  .replace(/\"/g, "&quot;")
2356
2495
  .replace(/'/g, "&apos;");
2357
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
+ }
@@ -577,56 +577,126 @@ function parseOoxmlNumericId(value: string): number | undefined {
577
577
  return Number.isFinite(numericId) ? numericId : undefined;
578
578
  }
579
579
 
580
- function mapParagraphBoundaries(documentXml: string): ParagraphBoundaryMap[] {
580
+ export function mapParagraphBoundaries(documentXml: string): ParagraphBoundaryMap[] {
581
581
  const root = parseXml(documentXml);
582
582
  const documentElement = findChildElement(root, "document");
583
583
  const bodyElement = findChildElement(documentElement, "body");
584
584
  const paragraphs: ParagraphBoundaryMap[] = [];
585
- let globalCursor = 0;
586
- let paragraphIndex = -1;
587
- let previousWasParagraph = false;
585
+ walkBlockNodesForParagraphBoundaries(
586
+ bodyElement.children,
587
+ documentXml,
588
+ paragraphs,
589
+ 0,
590
+ -1,
591
+ true,
592
+ );
593
+
594
+ return paragraphs;
595
+ }
596
+
597
+ function walkBlockNodesForParagraphBoundaries(
598
+ nodes: readonly XmlNode[],
599
+ documentXml: string,
600
+ paragraphs: ParagraphBoundaryMap[],
601
+ globalCursor: number,
602
+ paragraphIndex: number,
603
+ useSurfaceParagraphSeparators: boolean,
604
+ ): {
605
+ globalCursor: number;
606
+ paragraphIndex: number;
607
+ } {
608
+ let nextCursor = globalCursor;
609
+ let nextParagraphIndex = paragraphIndex;
610
+ let elementIndex = -1;
588
611
 
589
- for (const child of bodyElement.children) {
590
- if (child.type !== "element") {
612
+ for (const node of nodes) {
613
+ if (node.type !== "element") {
614
+ continue;
615
+ }
616
+ elementIndex += 1;
617
+
618
+ const name = localName(node.name);
619
+ if (name === "p") {
620
+ if (useSurfaceParagraphSeparators && elementIndex > 0) {
621
+ nextCursor += 1;
622
+ }
623
+ nextParagraphIndex += 1;
624
+ const boundaries = new Map<number, number>();
625
+ boundaries.set(nextCursor, node.start + openingTagLength(documentXml, node.start));
626
+
627
+ walkParagraphForBoundaries(
628
+ node,
629
+ documentXml,
630
+ boundaries,
631
+ () => nextCursor,
632
+ (next) => {
633
+ nextCursor = next;
634
+ },
635
+ );
636
+
637
+ if (!boundaries.has(nextCursor)) {
638
+ boundaries.set(nextCursor, node.end - 4);
639
+ }
640
+ paragraphs.push({
641
+ paragraphIndex: nextParagraphIndex,
642
+ start: Math.min(...boundaries.keys()),
643
+ end: Math.max(...boundaries.keys()),
644
+ boundaries,
645
+ });
591
646
  continue;
592
647
  }
593
648
 
594
- if (localName(child.name) !== "p") {
595
- globalCursor += 1;
596
- previousWasParagraph = false;
649
+ if (name === "tbl") {
650
+ for (const child of node.children) {
651
+ if (child.type !== "element" || localName(child.name) !== "tr") {
652
+ continue;
653
+ }
654
+ for (const rowChild of child.children) {
655
+ if (rowChild.type !== "element" || localName(rowChild.name) !== "tc") {
656
+ continue;
657
+ }
658
+ const result = walkBlockNodesForParagraphBoundaries(
659
+ rowChild.children,
660
+ documentXml,
661
+ paragraphs,
662
+ nextCursor,
663
+ nextParagraphIndex,
664
+ false,
665
+ );
666
+ nextCursor = result.globalCursor;
667
+ nextParagraphIndex = result.paragraphIndex;
668
+ }
669
+ }
597
670
  continue;
598
671
  }
599
672
 
600
- if (previousWasParagraph) {
601
- globalCursor += 1;
673
+ if (name === "sdt") {
674
+ const sdtContent = findChildElement(node, "sdtContent");
675
+ const result = walkBlockNodesForParagraphBoundaries(
676
+ sdtContent.children,
677
+ documentXml,
678
+ paragraphs,
679
+ nextCursor,
680
+ nextParagraphIndex,
681
+ false,
682
+ );
683
+ nextCursor = result.globalCursor;
684
+ nextParagraphIndex = result.paragraphIndex;
685
+ continue;
602
686
  }
603
- paragraphIndex += 1;
604
- const boundaries = new Map<number, number>();
605
- boundaries.set(globalCursor, child.start + openingTagLength(documentXml, child.start));
606
-
607
- walkParagraphForBoundaries(
608
- child,
609
- documentXml,
610
- boundaries,
611
- () => globalCursor,
612
- (next) => {
613
- globalCursor = next;
614
- },
615
- );
616
687
 
617
- if (!boundaries.has(globalCursor)) {
618
- boundaries.set(globalCursor, child.end - 4);
688
+ if (name === "customXml") {
689
+ nextCursor += 1;
690
+ continue;
619
691
  }
620
- paragraphs.push({
621
- paragraphIndex,
622
- start: Math.min(...boundaries.keys()),
623
- end: Math.max(...boundaries.keys()),
624
- boundaries,
625
- });
626
- previousWasParagraph = true;
692
+
693
+ nextCursor += 0;
627
694
  }
628
695
 
629
- return paragraphs;
696
+ return {
697
+ globalCursor: nextCursor,
698
+ paragraphIndex: nextParagraphIndex,
699
+ };
630
700
  }
631
701
 
632
702
  function walkParagraphForBoundaries(