@beyondwork/docx-react-component 1.0.14 → 1.0.16

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.
@@ -754,17 +754,17 @@ function readTableGridColumns(node: XmlElementNode): number[] {
754
754
  * Check if a table's raw XML contains content that cannot safely round-trip
755
755
  * through the parsed table path yet. This includes:
756
756
  * - Revision markup (tracked changes inside cells)
757
- * - Hyperlink relationships (original relationship IDs would be lost)
758
- * - Comment ranges, bookmarks, and other annotation markup
757
+ * - Field syntax and smart tags that still lack a safe table-local live model
758
+ * - Grid geometry tags that the table serializer does not reconstruct yet
759
759
  *
760
760
  * Tables matching this check stay opaque until the respective features
761
761
  * are implemented in the table editing path.
762
762
  */
763
763
  function tableRequiresOpaquePreservation(rawXml: string): boolean {
764
- // Tables with revision markup, complex content, or structural tags stay opaque.
765
- // Safe tags (hyperlinks, bookmarks, comments, basic formatting) are now allowed
766
- // since they are handled by the paragraph parser within table cells.
767
- return /<w:(ins|del|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|pict|fldChar|fldSimple|smartTag|gridAfter|gridBefore|tblCellSpacing)\b/.test(rawXml);
764
+ // Safe table-local content now includes hyperlinks, bookmarks, comments,
765
+ // nested tables, floating images, and VML preview atoms because the parser
766
+ // and serializer can preserve them without degrading the whole table.
767
+ return /<w:(ins|del|rPrChange|pPrChange|tblPrChange|trPrChange|tcPrChange|sectPrChange|cellIns|cellDel|cellMerge|fldChar|fldSimple|smartTag|gridAfter|gridBefore|tblCellSpacing)\b/.test(rawXml);
768
768
  }
769
769
 
770
770
  function readCellGridSpan(node: XmlElementNode): number | undefined {
@@ -68,7 +68,7 @@ interface RevisionMetadata {
68
68
 
69
69
  const SUPPORTED_CONTAINER_TYPES = new Set(["ins", "del"]);
70
70
  const PRESERVE_ONLY_CONTAINER_TYPES = new Set(["moveFrom", "moveTo"]);
71
- const FORMATTING_REVISION_TYPES = new Set(["rPrChange", "pPrChange"]);
71
+ const PROPERTY_CHANGE_REVISION_TYPES = new Set(["rPrChange", "pPrChange"]);
72
72
 
73
73
  export function parseRevisionsFromDocumentXml(
74
74
  documentXml: string,
@@ -158,16 +158,17 @@ function parseParagraphMarkRevisions(
158
158
 
159
159
  const type = localName(child.name);
160
160
  if (type === "pPrChange") {
161
- const metadata = readRevisionMetadata(child, state, "formatting");
161
+ const metadata = readRevisionMetadata(child, state, "property-change");
162
162
  const innerPPr = findChildElement(child, "pPr");
163
+ const beforeXml = innerPPr ? state.documentXml.slice(innerPPr.start, innerPPr.end) : "";
163
164
  const propertyChangeData: PropertyChangeData = {
164
165
  xmlTag: "pPrChange",
165
- beforeXml: innerPPr ? state.documentXml.slice(innerPPr.start, innerPPr.end) : "",
166
+ beforeXml,
166
167
  };
167
168
  state.revisions.push(
168
169
  createRevisionRecord({
169
170
  revisionId: metadata.revisionId,
170
- kind: "formatting",
171
+ kind: "property-change",
171
172
  anchor: paragraphRange,
172
173
  authorId: metadata.authorId,
173
174
  createdAt: metadata.createdAt,
@@ -175,7 +176,6 @@ function parseParagraphMarkRevisions(
175
176
  source: "import",
176
177
  originalRevisionType: "pPrChange",
177
178
  ooxmlRevisionId: metadata.ooxmlRevisionId,
178
- preserveOnlyReason: "Imported preserve-only revision.",
179
179
  propertyChangeData,
180
180
  },
181
181
  }),
@@ -186,12 +186,9 @@ function parseParagraphMarkRevisions(
186
186
  xmlStart: child.start,
187
187
  xmlEnd: child.end,
188
188
  originalRevisionType: "pPrChange",
189
- });
190
- state.diagnostics.push({
191
- revisionId: metadata.revisionId,
192
- code: "preserve_only_formatting_revision",
193
- message: "Paragraph property revisions remain preserve-only for Wave 6.",
194
- featureClass: "preserve-only",
189
+ containerXmlStart: paragraphProperties.start,
190
+ containerXmlEnd: paragraphProperties.end,
191
+ beforeContainerXml: beforeXml,
195
192
  });
196
193
  }
197
194
  }
@@ -356,7 +353,7 @@ function walkContentNode(
356
353
  state.diagnostics.push({
357
354
  revisionId: metadata.revisionId,
358
355
  code: "preserve_only_move_revision",
359
- message: "Tracked move revisions remain preserve-only for Wave 6.",
356
+ message: "Tracked move revisions are preserve-only. Content is preserved for round-trip but cannot be accepted or rejected.",
360
357
  featureClass: "preserve-only",
361
358
  });
362
359
  advanceCursor(node, setCursor, getCursor);
@@ -432,21 +429,22 @@ function parseRunFormattingRevisions(
432
429
  : createDetachedAnchor({ from: runStart, to: runStart }, "importAmbiguity");
433
430
 
434
431
  for (const child of runProperties.children) {
435
- if (child.type !== "element" || !FORMATTING_REVISION_TYPES.has(localName(child.name))) {
432
+ if (child.type !== "element" || !PROPERTY_CHANGE_REVISION_TYPES.has(localName(child.name))) {
436
433
  continue;
437
434
  }
438
435
 
439
436
  const childLocalName = localName(child.name) as "rPrChange" | "pPrChange";
440
- const metadata = readRevisionMetadata(child, state, "formatting");
437
+ const metadata = readRevisionMetadata(child, state, "property-change");
441
438
  const innerRPr = findChildElement(child, "rPr");
439
+ const beforeXml = innerRPr ? state.documentXml.slice(innerRPr.start, innerRPr.end) : "";
442
440
  const propertyChangeData: PropertyChangeData = {
443
441
  xmlTag: childLocalName === "rPrChange" ? "rPrChange" : "pPrChange",
444
- beforeXml: innerRPr ? state.documentXml.slice(innerRPr.start, innerRPr.end) : "",
442
+ beforeXml,
445
443
  };
446
444
  state.revisions.push(
447
445
  createRevisionRecord({
448
446
  revisionId: metadata.revisionId,
449
- kind: "formatting",
447
+ kind: "property-change",
450
448
  anchor,
451
449
  authorId: metadata.authorId,
452
450
  createdAt: metadata.createdAt,
@@ -454,7 +452,6 @@ function parseRunFormattingRevisions(
454
452
  source: "import",
455
453
  originalRevisionType: childLocalName,
456
454
  ooxmlRevisionId: metadata.ooxmlRevisionId,
457
- preserveOnlyReason: "Imported preserve-only revision.",
458
455
  propertyChangeData,
459
456
  },
460
457
  }),
@@ -465,12 +462,9 @@ function parseRunFormattingRevisions(
465
462
  xmlStart: child.start,
466
463
  xmlEnd: child.end,
467
464
  originalRevisionType: childLocalName,
468
- });
469
- state.diagnostics.push({
470
- revisionId: metadata.revisionId,
471
- code: "preserve_only_formatting_revision",
472
- message: "Formatting revisions remain preserve-only for Wave 6.",
473
- featureClass: "preserve-only",
465
+ containerXmlStart: runProperties.start,
466
+ containerXmlEnd: runProperties.end,
467
+ beforeContainerXml: beforeXml,
474
468
  });
475
469
  }
476
470
  }
@@ -616,7 +610,7 @@ function containsNestedRevision(node: XmlElementNode): boolean {
616
610
  child.type === "element" &&
617
611
  (SUPPORTED_CONTAINER_TYPES.has(localName(child.name)) ||
618
612
  PRESERVE_ONLY_CONTAINER_TYPES.has(localName(child.name)) ||
619
- FORMATTING_REVISION_TYPES.has(localName(child.name)) ||
613
+ PROPERTY_CHANGE_REVISION_TYPES.has(localName(child.name)) ||
620
614
  containsNestedRevision(child)),
621
615
  );
622
616
  }
@@ -167,6 +167,41 @@ export function isHiddenBookmarkName(name: string | undefined): boolean {
167
167
  return Boolean(name && name.startsWith("_"));
168
168
  }
169
169
 
170
+ export interface BookmarkIntegrityResult {
171
+ totalCount: number;
172
+ pairedCount: number;
173
+ startOnlyCount: number;
174
+ endOnlyCount: number;
175
+ hiddenCount: number;
176
+ namedCount: number;
177
+ integrityScore: "complete" | "partial" | "degraded";
178
+ }
179
+
180
+ export function assessBookmarkIntegrity(bookmarks: LegalBookmark[]): BookmarkIntegrityResult {
181
+ const pairedCount = bookmarks.filter((b) => b.status === "paired").length;
182
+ const startOnlyCount = bookmarks.filter((b) => b.status === "start-only").length;
183
+ const endOnlyCount = bookmarks.filter((b) => b.status === "end-only").length;
184
+ const hiddenCount = bookmarks.filter((b) => b.hidden).length;
185
+ const namedCount = bookmarks.filter((b) => b.name && !b.hidden).length;
186
+ const totalCount = bookmarks.length;
187
+ const unpairedCount = startOnlyCount + endOnlyCount;
188
+
189
+ return {
190
+ totalCount,
191
+ pairedCount,
192
+ startOnlyCount,
193
+ endOnlyCount,
194
+ hiddenCount,
195
+ namedCount,
196
+ integrityScore:
197
+ unpairedCount === 0
198
+ ? "complete"
199
+ : unpairedCount <= pairedCount
200
+ ? "partial"
201
+ : "degraded",
202
+ };
203
+ }
204
+
170
205
  function compareBookmarks(left: LegalBookmark, right: LegalBookmark): number {
171
206
  return (
172
207
  (left.startIndex ?? left.endIndex ?? Number.MAX_SAFE_INTEGER) -
@@ -0,0 +1,32 @@
1
+ export {
2
+ parseBookmarksFromDocumentXml,
3
+ collectBookmarksFromCanonicalDocument,
4
+ assessBookmarkIntegrity,
5
+ isHiddenBookmarkName,
6
+ type BookmarkIntegrityResult,
7
+ type LegalBookmark,
8
+ } from "./bookmarks.ts";
9
+
10
+ export {
11
+ parseCrossReferencesFromDocumentXml,
12
+ collectCrossReferencesFromCanonicalDocument,
13
+ detectCrossReferencePatterns,
14
+ parseFieldReferenceInstruction,
15
+ type CrossReference,
16
+ type CrossReferencePattern,
17
+ } from "./cross-references.ts";
18
+
19
+ export {
20
+ collectDefinedTermsFromDocumentXml,
21
+ collectDefinedTermsFromCanonicalDocument,
22
+ buildDefinedTermCatalog,
23
+ type DefinedTerm,
24
+ type DefinedTermOccurrence,
25
+ } from "./defined-terms.ts";
26
+
27
+ export {
28
+ detectSignatureBlocksFromCanonicalDocument,
29
+ type SignatureBlockCandidate,
30
+ type SignatureBlockReport,
31
+ type SignatureParty,
32
+ } from "./signature-blocks.ts";
@@ -0,0 +1,259 @@
1
+ import type {
2
+ BlockNode,
3
+ CanonicalDocument,
4
+ DocumentNode,
5
+ ParagraphNode,
6
+ } from "../model/canonical-document.ts";
7
+
8
+ export interface SignatureBlockCandidate {
9
+ startIndex: number;
10
+ endIndex: number;
11
+ kind: "execution-block" | "witness-block" | "notary-block";
12
+ parties: SignatureParty[];
13
+ confidence: "high" | "medium";
14
+ }
15
+
16
+ export interface SignatureParty {
17
+ roleLabel?: string;
18
+ nameLabel?: string;
19
+ paragraphIndex: number;
20
+ }
21
+
22
+ export interface SignatureBlockReport {
23
+ reportVersion: "signature-block-report/1";
24
+ candidates: SignatureBlockCandidate[];
25
+ preservationSafe: boolean;
26
+ warnings: string[];
27
+ }
28
+
29
+ const EXECUTION_TRIGGER_PATTERN =
30
+ /\b(?:IN WITNESS WHEREOF|EXECUTED|AGREED AND ACCEPTED|SIGNATURES FOLLOW)\b/i;
31
+ const ROLE_LINE_PATTERN =
32
+ /^\s*(?:By|Name|Title|Date|Witness|Authorized Signatory|Signature)\s*[:\-]?\s*/i;
33
+ const PARTY_LABEL_PATTERN =
34
+ /^(?:(?:The |)[A-Z][A-Za-z0-9 ,.'&()-]{2,60})\s*$/;
35
+ const WITNESS_PATTERN = /\bWITNESS(?:ED|ES)?\b/i;
36
+ const NOTARY_PATTERN = /\bNOTARY\s+PUBLIC\b/i;
37
+ const UNDERLINE_PLACEHOLDER_PATTERN = /_{4,}|\.{4,}/;
38
+
39
+ export function detectSignatureBlocksFromCanonicalDocument(
40
+ document: Pick<CanonicalDocument, "content" | "preservation"> | DocumentNode,
41
+ ): SignatureBlockReport {
42
+ const root = "content" in document ? document.content : document;
43
+ const paragraphs: Array<{ text: string; node: ParagraphNode }> = [];
44
+ const warnings: string[] = [];
45
+
46
+ walkBlocks(root, (node) => {
47
+ if (node.type === "paragraph") {
48
+ paragraphs.push({ text: flattenParagraphText(node), node });
49
+ }
50
+ });
51
+
52
+ const candidates = findSignatureBlockCandidates(paragraphs);
53
+
54
+ const hasPreservation =
55
+ "preservation" in document &&
56
+ document.preservation !== undefined;
57
+ const preservationSafe = hasPreservation
58
+ ? !hasOpaqueFragmentsInSignatureRanges(
59
+ document as Pick<CanonicalDocument, "content" | "preservation">,
60
+ candidates,
61
+ )
62
+ : true;
63
+
64
+ if (!preservationSafe) {
65
+ warnings.push(
66
+ "One or more signature block regions overlap with preserve-only opaque fragments. Export fidelity may be degraded.",
67
+ );
68
+ }
69
+
70
+ return {
71
+ reportVersion: "signature-block-report/1",
72
+ candidates,
73
+ preservationSafe,
74
+ warnings,
75
+ };
76
+ }
77
+
78
+ function findSignatureBlockCandidates(
79
+ paragraphs: ReadonlyArray<{ text: string; node: ParagraphNode }>,
80
+ ): SignatureBlockCandidate[] {
81
+ const candidates: SignatureBlockCandidate[] = [];
82
+ let index = 0;
83
+
84
+ while (index < paragraphs.length) {
85
+ const text = paragraphs[index].text;
86
+
87
+ if (EXECUTION_TRIGGER_PATTERN.test(text)) {
88
+ const block = scanSignatureBlock(paragraphs, index);
89
+ if (block) {
90
+ candidates.push(block);
91
+ index = block.endIndex + 1;
92
+ continue;
93
+ }
94
+ }
95
+
96
+ index += 1;
97
+ }
98
+
99
+ return candidates;
100
+ }
101
+
102
+ function scanSignatureBlock(
103
+ paragraphs: ReadonlyArray<{ text: string; node: ParagraphNode }>,
104
+ triggerIndex: number,
105
+ ): SignatureBlockCandidate | undefined {
106
+ const parties: SignatureParty[] = [];
107
+ let endIndex = triggerIndex;
108
+ let kind: SignatureBlockCandidate["kind"] = "execution-block";
109
+ let hasSignatureContent = false;
110
+
111
+ for (
112
+ let index = triggerIndex + 1;
113
+ index < paragraphs.length && index <= triggerIndex + 40;
114
+ index += 1
115
+ ) {
116
+ const text = paragraphs[index].text.trim();
117
+
118
+ if (text.length === 0) {
119
+ endIndex = index;
120
+ continue;
121
+ }
122
+
123
+ if (WITNESS_PATTERN.test(text)) {
124
+ kind = "witness-block";
125
+ }
126
+ if (NOTARY_PATTERN.test(text)) {
127
+ kind = "notary-block";
128
+ }
129
+
130
+ if (ROLE_LINE_PATTERN.test(text)) {
131
+ hasSignatureContent = true;
132
+ endIndex = index;
133
+ continue;
134
+ }
135
+
136
+ if (UNDERLINE_PLACEHOLDER_PATTERN.test(text)) {
137
+ hasSignatureContent = true;
138
+ endIndex = index;
139
+ continue;
140
+ }
141
+
142
+ if (PARTY_LABEL_PATTERN.test(text) && !isBodyParagraph(text)) {
143
+ parties.push({
144
+ roleLabel: text.trim(),
145
+ paragraphIndex: index,
146
+ });
147
+ hasSignatureContent = true;
148
+ endIndex = index;
149
+ continue;
150
+ }
151
+
152
+ if (hasSignatureContent && isBodyParagraph(text)) {
153
+ break;
154
+ }
155
+
156
+ endIndex = index;
157
+ }
158
+
159
+ if (!hasSignatureContent) {
160
+ return undefined;
161
+ }
162
+
163
+ return {
164
+ startIndex: triggerIndex,
165
+ endIndex,
166
+ kind,
167
+ parties,
168
+ confidence: parties.length > 0 ? "high" : "medium",
169
+ };
170
+ }
171
+
172
+ function isBodyParagraph(text: string): boolean {
173
+ return text.length > 120 && /[.;]/.test(text);
174
+ }
175
+
176
+ function hasOpaqueFragmentsInSignatureRanges(
177
+ document: Pick<CanonicalDocument, "content" | "preservation">,
178
+ candidates: readonly SignatureBlockCandidate[],
179
+ ): boolean {
180
+ if (candidates.length === 0) {
181
+ return false;
182
+ }
183
+
184
+ const opaqueCount = Object.keys(document.preservation.opaqueFragments).length;
185
+ if (opaqueCount === 0) {
186
+ return false;
187
+ }
188
+
189
+ let paragraphIndex = 0;
190
+ const opaqueIndices = new Set<number>();
191
+
192
+ for (const block of document.content.children) {
193
+ if (block.type === "paragraph") {
194
+ paragraphIndex += 1;
195
+ continue;
196
+ }
197
+ if (block.type === "opaque_block") {
198
+ opaqueIndices.add(paragraphIndex);
199
+ }
200
+ paragraphIndex += 1;
201
+ }
202
+
203
+ return candidates.some((candidate) => {
204
+ for (let index = candidate.startIndex; index <= candidate.endIndex; index += 1) {
205
+ if (opaqueIndices.has(index)) {
206
+ return true;
207
+ }
208
+ }
209
+ return false;
210
+ });
211
+ }
212
+
213
+ function flattenParagraphText(paragraph: ParagraphNode): string {
214
+ return paragraph.children
215
+ .map((child) => {
216
+ switch (child.type) {
217
+ case "text":
218
+ return child.text;
219
+ case "hyperlink":
220
+ case "field":
221
+ return child.children
222
+ .map((nested) =>
223
+ nested.type === "text" ? nested.text : nested.type === "tab" ? "\t" : "",
224
+ )
225
+ .join("");
226
+ case "tab":
227
+ return "\t";
228
+ case "hard_break":
229
+ case "column_break":
230
+ return "\n";
231
+ default:
232
+ return "";
233
+ }
234
+ })
235
+ .join("");
236
+ }
237
+
238
+ function walkBlocks(
239
+ node: DocumentNode,
240
+ visit: (node: DocumentNode) => void,
241
+ ): void {
242
+ visit(node);
243
+
244
+ if ("children" in node && Array.isArray(node.children)) {
245
+ for (const child of node.children) {
246
+ walkBlocks(child, visit);
247
+ }
248
+ }
249
+
250
+ if (node.type === "table") {
251
+ for (const row of node.rows) {
252
+ walkBlocks(row, visit);
253
+ }
254
+ } else if (node.type === "table_row") {
255
+ for (const cell of node.cells) {
256
+ walkBlocks(cell, visit);
257
+ }
258
+ }
259
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createEditorState,
3
+ createSelectionSnapshot,
3
4
  createPersistedEditorSnapshot,
4
5
  deriveDocumentStats,
5
6
  type CanonicalDocumentEnvelope,
@@ -35,6 +36,7 @@ import {
35
36
  type EditorCommand,
36
37
  type EditorTransaction,
37
38
  } from "../core/commands/index.ts";
39
+ import { insertText } from "../core/commands/text-commands.ts";
38
40
  import {
39
41
  createDetachedAnchor,
40
42
  createNodeAnchor,
@@ -51,6 +53,7 @@ import {
51
53
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
52
54
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
53
55
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
56
+ import { getFormattingStateFromRenderSnapshot } from "../core/commands/formatting-commands.ts";
54
57
 
55
58
  export type Unsubscribe = () => void;
56
59
 
@@ -58,11 +61,13 @@ export interface DocumentRuntime {
58
61
  subscribe(listener: () => void): Unsubscribe;
59
62
  subscribeToEvents(listener: (event: WordReviewEditorEvent) => void): Unsubscribe;
60
63
  getRenderSnapshot(): RuntimeRenderSnapshot;
64
+ getFormattingState(): import("../api/public-types").FormattingStateSnapshot;
61
65
  dispatch(command: EditorCommand): void;
62
66
  undo(): void;
63
67
  redo(): void;
64
68
  focus(): void;
65
69
  blur(): void;
70
+ replaceText(text: string, target?: EditorAnchorProjection): void;
66
71
  addComment(params: AddCommentParams): string;
67
72
  openComment(commentId: string): void;
68
73
  resolveComment(commentId: string): void;
@@ -162,6 +167,9 @@ export function createDocumentRuntime(
162
167
  getRenderSnapshot() {
163
168
  return cachedRenderSnapshot;
164
169
  },
170
+ getFormattingState() {
171
+ return getFormattingStateFromRenderSnapshot(cachedRenderSnapshot);
172
+ },
165
173
  dispatch(command) {
166
174
  if (command.type === "history.undo") {
167
175
  applyHistory("undo");
@@ -208,6 +216,25 @@ export function createDocumentRuntime(
208
216
  origin: createOrigin("api", clock()),
209
217
  });
210
218
  },
219
+ replaceText(text, target) {
220
+ try {
221
+ const timestamp = clock();
222
+ const selection = target
223
+ ? createSelectionFromPublicAnchor(target)
224
+ : state.selection;
225
+ const result = insertText(state.document, selection, text, { timestamp });
226
+
227
+ this.dispatch({
228
+ type: "document.replace",
229
+ document: result.document,
230
+ mapping: result.mapping,
231
+ selection: result.selection,
232
+ origin: createOrigin("api", timestamp),
233
+ });
234
+ } catch (error) {
235
+ emitError(toRuntimeError(error));
236
+ }
237
+ },
211
238
  addComment(params) {
212
239
  const commentId = createEntityId("comment", state.document.review.comments, clock());
213
240
  const anchor = params.anchor
@@ -694,6 +721,22 @@ function toInternalAnchorProjection(
694
721
  }
695
722
  }
696
723
 
724
+ function createSelectionFromPublicAnchor(
725
+ anchor: EditorAnchorProjection,
726
+ ): import("../core/state/editor-state.ts").SelectionSnapshot {
727
+ switch (anchor.kind) {
728
+ case "range":
729
+ return createSelectionSnapshot(anchor.from, anchor.to);
730
+ case "node":
731
+ return createSelectionSnapshot(anchor.at, anchor.at);
732
+ case "detached":
733
+ return createSelectionSnapshot(
734
+ anchor.lastKnownRange.from,
735
+ anchor.lastKnownRange.to,
736
+ );
737
+ }
738
+ }
739
+
697
740
  function toPublicCompatibilityReport(
698
741
  report: InternalCompatibilityReport,
699
742
  ): CompatibilityReport {