@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.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.14",
4
+ "version": "1.0.16",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
+ "packageManager": "pnpm@10.30.3",
6
7
  "type": "module",
7
8
  "sideEffects": [
8
9
  "**/*.css"
@@ -33,10 +34,18 @@
33
34
  "types": "./src/api/public-types.ts",
34
35
  "import": "./src/api/public-types.ts"
35
36
  },
37
+ "./compare": {
38
+ "types": "./src/compare/index.ts",
39
+ "import": "./src/compare/index.ts"
40
+ },
36
41
  "./io/docx-session": {
37
42
  "types": "./src/io/docx-session.ts",
38
43
  "import": "./src/io/docx-session.ts"
39
44
  },
45
+ "./legal": {
46
+ "types": "./src/legal/index.ts",
47
+ "import": "./src/legal/index.ts"
48
+ },
40
49
  "./runtime/document-runtime": {
41
50
  "types": "./src/runtime/document-runtime.ts",
42
51
  "import": "./src/runtime/document-runtime.ts"
@@ -45,6 +54,29 @@
45
54
  "./package.json": "./package.json"
46
55
  },
47
56
  "types": "./src/index.ts",
57
+ "scripts": {
58
+ "build": "tsup",
59
+ "test": "bash scripts/run-workspace-tests.sh",
60
+ "test:repo": "node scripts/run-repo-tests.mjs core",
61
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
62
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
63
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
64
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
65
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
66
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
67
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
68
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
69
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
70
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
71
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
72
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
73
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
74
+ "wave:status": "bash scripts/wave-status.sh",
75
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
76
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
77
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
78
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
79
+ },
48
80
  "keywords": [
49
81
  "docx",
50
82
  "word",
@@ -90,7 +122,7 @@
90
122
  "tailwindcss": "^4.2.2"
91
123
  },
92
124
  "devDependencies": {
93
- "@chllming/wave-orchestration": "^0.9.13",
125
+ "@chllming/wave-orchestration": "^0.9.15",
94
126
  "@types/react": "19.2.14",
95
127
  "@types/react-dom": "19.2.3",
96
128
  "@typescript/native-preview": "7.0.0-dev.20260409.1",
@@ -107,27 +139,14 @@
107
139
  "tsup": "^8.3.0",
108
140
  "tsx": "^4.21.0"
109
141
  },
110
- "scripts": {
111
- "build": "tsup",
112
- "test": "bash scripts/run-workspace-tests.sh",
113
- "test:repo": "node scripts/run-repo-tests.mjs core",
114
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
115
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
116
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
117
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
118
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
119
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
120
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
121
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
122
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
123
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
124
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
125
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
126
- "wave:launch:managed": "bash scripts/wave-launch.sh",
127
- "wave:status": "bash scripts/wave-status.sh",
128
- "wave:watch": "bash scripts/wave-watch.sh --follow",
129
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
130
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
131
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
142
+ "pnpm": {
143
+ "onlyBuiltDependencies": [
144
+ "esbuild",
145
+ "sharp"
146
+ ],
147
+ "overrides": {
148
+ "react": "19.2.4",
149
+ "react-dom": "19.2.4"
150
+ }
132
151
  }
133
152
  }
@@ -125,6 +125,13 @@ export interface TrackedChangesSnapshot {
125
125
 
126
126
  export type FormattingAlignment = "left" | "center" | "right" | "justify";
127
127
 
128
+ export interface FormattingBreadcrumbItem {
129
+ kind: "table" | "table_row" | "table_cell" | "paragraph" | "sdt_block" | "opaque_block";
130
+ label: string;
131
+ from: number;
132
+ to: number;
133
+ }
134
+
128
135
  export interface FormattingStateSnapshot {
129
136
  bold: boolean;
130
137
  italic: boolean;
@@ -137,6 +144,7 @@ export interface FormattingStateSnapshot {
137
144
  textColor?: string;
138
145
  highlightColor?: string | null;
139
146
  alignment?: FormattingAlignment;
147
+ breadcrumb: FormattingBreadcrumbItem[];
140
148
  }
141
149
 
142
150
  export interface SearchOptions {
@@ -571,17 +579,23 @@ export interface WordReviewEditorRef {
571
579
  reopenComment(commentId: string): void;
572
580
  addCommentReply(commentId: string, body: string): void;
573
581
  editCommentBody(commentId: string, body: string): void;
582
+ deleteComment(commentId: string): void;
574
583
  acceptChange(changeId: string): void;
575
584
  rejectChange(changeId: string): void;
576
585
  acceptAllChanges(): void;
577
586
  rejectAllChanges(): void;
578
587
  exportDocx(options?: ExportDocxOptions): Promise<ExportResult>;
579
588
  getSnapshot(): PersistedEditorSnapshot;
589
+ getRenderSnapshot(): RuntimeRenderSnapshot;
580
590
  getCompatibilityReport(): CompatibilityReport;
581
591
  getWarnings(): EditorWarning[];
592
+ getCommentSidebarSnapshot(): CommentSidebarSnapshot;
593
+ getTrackedChangesSnapshot(): TrackedChangesSnapshot;
582
594
  getComments(): CommentSidebarSnapshot;
583
595
  getTrackedChanges(): TrackedChangesSnapshot;
596
+ isDirty(): boolean;
584
597
  getFormattingState(): FormattingStateSnapshot;
598
+ replaceText(text: string, target?: EditorAnchorProjection): void;
585
599
  toggleBold(): void;
586
600
  toggleItalic(): void;
587
601
  toggleUnderline(): void;
@@ -610,6 +624,7 @@ export interface WordReviewEditorRef {
610
624
  setCellBackground(color: string): void;
611
625
  search(query: string, options?: SearchOptions): SearchResultSnapshot[];
612
626
  clearSearch(): void;
627
+ setSelection(selection: SelectionSnapshot | null): void;
613
628
  scrollToRevision(revisionId: string): void;
614
629
  scrollToComment(commentId: string): void;
615
630
  }
@@ -20,6 +20,7 @@ export interface CompareVersionRef {
20
20
  export type CompareChangeKind =
21
21
  | "paragraph-insertion"
22
22
  | "paragraph-deletion"
23
+ | "paragraph-modification"
23
24
  | "structural-insertion"
24
25
  | "structural-deletion";
25
26
 
@@ -96,6 +97,22 @@ export function compareDocumentSnapshots(
96
97
  const insertBlock = nextTargetIndex < match.targetIndex ? targetBlocks[nextTargetIndex] : undefined;
97
98
 
98
99
  if (deleteBlock && insertBlock) {
100
+ if (deleteBlock.type === "paragraph" && insertBlock.type === "paragraph") {
101
+ const modificationId = `change-${nextChangeNumber}`;
102
+ nextChangeNumber += 1;
103
+ addParagraphModificationEntry(
104
+ buildEntries,
105
+ changes,
106
+ modificationId,
107
+ deleteBlock,
108
+ insertBlock,
109
+ nextTargetIndex,
110
+ );
111
+ nextBaseIndex += 1;
112
+ nextTargetIndex += 1;
113
+ continue;
114
+ }
115
+
99
116
  const deletionId = `change-${nextChangeNumber}`;
100
117
  nextChangeNumber += 1;
101
118
  addTrackedOrStructuralEntry(
@@ -153,13 +170,34 @@ export function compareDocumentSnapshots(
153
170
  }
154
171
 
155
172
  if (match.baseIndex < baseBlocks.length && match.targetIndex < targetBlocks.length) {
156
- buildEntries.push({
157
- block: cloneBlock(targetBlocks[match.targetIndex]),
158
- tracked: false,
159
- blockType: targetBlocks[match.targetIndex].type,
160
- beforeText: getBlockDisplayText(baseBlocks[match.baseIndex]),
161
- afterText: getBlockDisplayText(targetBlocks[match.targetIndex]),
162
- });
173
+ const baseText = getBlockDisplayText(baseBlocks[match.baseIndex]);
174
+ const targetText = getBlockDisplayText(targetBlocks[match.targetIndex]);
175
+ const isModified =
176
+ baseBlocks[match.baseIndex].type === "paragraph" &&
177
+ targetBlocks[match.targetIndex].type === "paragraph" &&
178
+ baseText !== targetText;
179
+
180
+ if (isModified) {
181
+ const modChangeId = `change-${nextChangeNumber}`;
182
+ nextChangeNumber += 1;
183
+ addParagraphModificationEntry(
184
+ buildEntries,
185
+ changes,
186
+ modChangeId,
187
+ baseBlocks[match.baseIndex] as ParagraphNode,
188
+ targetBlocks[match.targetIndex] as ParagraphNode,
189
+ match.targetIndex,
190
+ );
191
+ } else {
192
+ buildEntries.push({
193
+ block: cloneBlock(targetBlocks[match.targetIndex]),
194
+ tracked: false,
195
+ blockType: targetBlocks[match.targetIndex].type,
196
+ beforeText: baseText,
197
+ afterText: targetText,
198
+ });
199
+ }
200
+
163
201
  nextBaseIndex = match.baseIndex + 1;
164
202
  nextTargetIndex = match.targetIndex + 1;
165
203
  }
@@ -222,6 +260,45 @@ function addTrackedOrStructuralEntry(
222
260
  });
223
261
  }
224
262
 
263
+ function addParagraphModificationEntry(
264
+ buildEntries: BuildEntry[],
265
+ changes: CompareChange[],
266
+ changeId: string,
267
+ baseParagraph: ParagraphNode,
268
+ targetParagraph: ParagraphNode,
269
+ targetIndex: number,
270
+ ): void {
271
+ const beforeText = getBlockDisplayText(baseParagraph);
272
+ const afterText = getBlockDisplayText(targetParagraph);
273
+
274
+ buildEntries.push({
275
+ changeId: `${changeId}-before`,
276
+ block: cloneBlock(baseParagraph),
277
+ trackChange: "deletion",
278
+ tracked: true,
279
+ blockType: "paragraph",
280
+ beforeText,
281
+ });
282
+ buildEntries.push({
283
+ changeId: `${changeId}-after`,
284
+ block: cloneBlock(targetParagraph),
285
+ trackChange: "insertion",
286
+ tracked: true,
287
+ blockType: "paragraph",
288
+ afterText,
289
+ });
290
+
291
+ changes.push({
292
+ changeId,
293
+ kind: "paragraph-modification",
294
+ tracked: true,
295
+ blockType: "paragraph",
296
+ beforeText,
297
+ afterText,
298
+ targetIndex,
299
+ });
300
+ }
301
+
225
302
  function buildComparedDocument(
226
303
  base: CanonicalDocument,
227
304
  target: CanonicalDocument,
@@ -0,0 +1,25 @@
1
+ export {
2
+ compareDocumentSnapshots,
3
+ type CompareChange,
4
+ type CompareChangeKind,
5
+ type CompareDocumentVersionsOptions,
6
+ type CompareVersionRef,
7
+ type VersionCompareResult,
8
+ } from "./diff-engine.ts";
9
+
10
+ export {
11
+ exportComparedDocumentRedlines,
12
+ type ExportComparedDocumentOptions,
13
+ type ExportComparedDocumentResult,
14
+ } from "./export-redlines.ts";
15
+
16
+ export {
17
+ buildVersionAuditTrail,
18
+ createDocumentVersionSnapshot,
19
+ createDocumentVersionSnapshotId,
20
+ lockDocumentVersionSnapshot,
21
+ saveDocumentVersionSnapshot,
22
+ type CreateDocumentVersionSnapshotOptions,
23
+ type DocumentVersionSnapshot,
24
+ type VersionAuditEntry,
25
+ } from "./snapshot.ts";
@@ -53,6 +53,37 @@ export function saveDocumentVersionSnapshot(
53
53
  return next.sort(compareDocumentVersionSnapshots);
54
54
  }
55
55
 
56
+ export interface VersionAuditEntry {
57
+ versionId: string;
58
+ name: string;
59
+ createdAt: string;
60
+ documentSignature: string;
61
+ action: "created" | "compared" | "locked";
62
+ }
63
+
64
+ export function buildVersionAuditTrail(
65
+ snapshots: readonly DocumentVersionSnapshot[],
66
+ ): VersionAuditEntry[] {
67
+ return snapshots.map((snapshot) => ({
68
+ versionId: snapshot.versionId,
69
+ name: snapshot.name,
70
+ createdAt: snapshot.createdAt,
71
+ documentSignature: snapshot.documentSignature,
72
+ action: "created",
73
+ }));
74
+ }
75
+
76
+ export function lockDocumentVersionSnapshot(
77
+ snapshot: DocumentVersionSnapshot,
78
+ ): DocumentVersionSnapshot {
79
+ return {
80
+ ...snapshot,
81
+ name: snapshot.name.startsWith("[locked] ")
82
+ ? snapshot.name
83
+ : `[locked] ${snapshot.name}`,
84
+ };
85
+ }
86
+
56
87
  function compareDocumentVersionSnapshots(
57
88
  left: DocumentVersionSnapshot,
58
89
  right: DocumentVersionSnapshot,
@@ -21,12 +21,14 @@ import type { Command } from "prosemirror-state";
21
21
  import type { MarkType, Schema } from "prosemirror-model";
22
22
 
23
23
  import type {
24
+ FormattingBreadcrumbItem,
24
25
  FormattingAlignment,
25
26
  FormattingStateSnapshot,
26
27
  PersistedEditorSnapshot,
27
28
  RuntimeRenderSnapshot,
28
29
  SurfaceBlockSnapshot,
29
30
  SurfaceInlineSegment,
31
+ SurfaceTableCellSnapshot,
30
32
  } from "../../api/public-types";
31
33
  import type {
32
34
  BlockNode,
@@ -209,6 +211,7 @@ const DEFAULT_FORMATTING_STATE: FormattingStateSnapshot = {
209
211
  strikethrough: false,
210
212
  superscript: false,
211
213
  subscript: false,
214
+ breadcrumb: [],
212
215
  };
213
216
 
214
217
  const INDENT_STEP_TWIPS = 720;
@@ -268,9 +271,231 @@ export function getFormattingStateFromRenderSnapshot(
268
271
  alignment: getConsistentValue(paragraphs, (paragraph) =>
269
272
  toPublicAlignment(paragraph.alignment),
270
273
  ),
274
+ breadcrumb: getSelectionBreadcrumb(surface.blocks, snapshot.selection),
271
275
  };
272
276
  }
273
277
 
278
+ function getSelectionBreadcrumb(
279
+ blocks: SurfaceBlockSnapshot[],
280
+ selection: RuntimeRenderSnapshot["selection"],
281
+ ): FormattingBreadcrumbItem[] {
282
+ const paths = collectSelectionBreadcrumbPaths(blocks, selection);
283
+ if (paths.length === 0) {
284
+ return [];
285
+ }
286
+
287
+ let commonLength = paths[0]?.length ?? 0;
288
+ for (let pathIndex = 1; pathIndex < paths.length; pathIndex += 1) {
289
+ const currentPath = paths[pathIndex] ?? [];
290
+ commonLength = Math.min(commonLength, currentPath.length);
291
+
292
+ let compareIndex = 0;
293
+ while (
294
+ compareIndex < commonLength &&
295
+ breadcrumbItemsEqual(paths[0]![compareIndex]!, currentPath[compareIndex]!)
296
+ ) {
297
+ compareIndex += 1;
298
+ }
299
+ commonLength = compareIndex;
300
+ }
301
+
302
+ return paths[0]!.slice(0, commonLength);
303
+ }
304
+
305
+ function collectSelectionBreadcrumbPaths(
306
+ blocks: SurfaceBlockSnapshot[],
307
+ selection: RuntimeRenderSnapshot["selection"],
308
+ path: FormattingBreadcrumbItem[] = [],
309
+ output: FormattingBreadcrumbItem[][] = [],
310
+ ): FormattingBreadcrumbItem[][] {
311
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) {
312
+ const block = blocks[blockIndex];
313
+ if (!block) {
314
+ continue;
315
+ }
316
+
317
+ if (
318
+ !selectionTouchesRange(
319
+ selection.anchor,
320
+ selection.head,
321
+ block.from,
322
+ block.to,
323
+ )
324
+ ) {
325
+ continue;
326
+ }
327
+
328
+ if (block.kind === "paragraph") {
329
+ output.push([
330
+ ...path,
331
+ {
332
+ kind: "paragraph" as const,
333
+ label: block.styleId ? `Paragraph (${block.styleId})` : "Paragraph",
334
+ from: block.from,
335
+ to: block.to,
336
+ },
337
+ ]);
338
+ continue;
339
+ }
340
+
341
+ if (block.kind === "opaque_block") {
342
+ output.push([
343
+ ...path,
344
+ {
345
+ kind: "opaque_block" as const,
346
+ label: block.label,
347
+ from: block.from,
348
+ to: block.to,
349
+ },
350
+ ]);
351
+ continue;
352
+ }
353
+
354
+ if (block.kind === "sdt_block") {
355
+ collectSelectionBreadcrumbPaths(
356
+ block.children,
357
+ selection,
358
+ [
359
+ ...path,
360
+ {
361
+ kind: "sdt_block" as const,
362
+ label: block.alias ?? block.tag ?? block.sdtType ?? "Content control",
363
+ from: block.from,
364
+ to: block.to,
365
+ },
366
+ ],
367
+ output,
368
+ );
369
+ continue;
370
+ }
371
+
372
+ if (block.kind === "table") {
373
+ const tablePath = [
374
+ ...path,
375
+ {
376
+ kind: "table" as const,
377
+ label: block.styleId ? `Table (${block.styleId})` : "Table",
378
+ from: block.from,
379
+ to: block.to,
380
+ },
381
+ ];
382
+
383
+ for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex += 1) {
384
+ const row = block.rows[rowIndex];
385
+ if (!row) {
386
+ continue;
387
+ }
388
+ const rowRange = getTableRowRange(block, row);
389
+ if (
390
+ !selectionTouchesRange(
391
+ selection.anchor,
392
+ selection.head,
393
+ rowRange.from,
394
+ rowRange.to,
395
+ )
396
+ ) {
397
+ continue;
398
+ }
399
+
400
+ const rowPath = [
401
+ ...tablePath,
402
+ {
403
+ kind: "table_row" as const,
404
+ label: `Row ${rowIndex + 1}`,
405
+ from: rowRange.from,
406
+ to: rowRange.to,
407
+ },
408
+ ];
409
+
410
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
411
+ const cell = row.cells[cellIndex];
412
+ if (!cell) {
413
+ continue;
414
+ }
415
+ const cellRange = getTableCellRange(block, cell);
416
+ if (
417
+ !selectionTouchesRange(
418
+ selection.anchor,
419
+ selection.head,
420
+ cellRange.from,
421
+ cellRange.to,
422
+ )
423
+ ) {
424
+ continue;
425
+ }
426
+
427
+ collectSelectionBreadcrumbPaths(
428
+ cell.content,
429
+ selection,
430
+ [
431
+ ...rowPath,
432
+ {
433
+ kind: "table_cell",
434
+ label: `Cell ${cellIndex + 1}`,
435
+ from: cellRange.from,
436
+ to: cellRange.to,
437
+ },
438
+ ],
439
+ output,
440
+ );
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ return output;
447
+ }
448
+
449
+ function getTableRowRange(
450
+ table: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
451
+ row: (typeof table.rows)[number],
452
+ ): { from: number; to: number } {
453
+ let from = Number.POSITIVE_INFINITY;
454
+ let to = Number.NEGATIVE_INFINITY;
455
+
456
+ for (const cell of row.cells) {
457
+ const range = getTableCellRange(table, cell);
458
+ from = Math.min(from, range.from);
459
+ to = Math.max(to, range.to);
460
+ }
461
+
462
+ return Number.isFinite(from) && Number.isFinite(to)
463
+ ? { from, to }
464
+ : { from: table.from, to: table.to };
465
+ }
466
+
467
+ function getTableCellRange(
468
+ table: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
469
+ cell: SurfaceTableCellSnapshot,
470
+ ): { from: number; to: number } {
471
+ if (cell.content.length === 0) {
472
+ return { from: table.from, to: table.to };
473
+ }
474
+
475
+ let from = Number.POSITIVE_INFINITY;
476
+ let to = Number.NEGATIVE_INFINITY;
477
+ for (const child of cell.content) {
478
+ from = Math.min(from, child.from);
479
+ to = Math.max(to, child.to);
480
+ }
481
+
482
+ return Number.isFinite(from) && Number.isFinite(to)
483
+ ? { from, to }
484
+ : { from: table.from, to: table.to };
485
+ }
486
+
487
+ function breadcrumbItemsEqual(
488
+ left: FormattingBreadcrumbItem,
489
+ right: FormattingBreadcrumbItem,
490
+ ): boolean {
491
+ return (
492
+ left.kind === right.kind &&
493
+ left.label === right.label &&
494
+ left.from === right.from &&
495
+ left.to === right.to
496
+ );
497
+ }
498
+
274
499
  export function applyFormattingOperationToDocument(
275
500
  document: CanonicalDocumentEnvelope,
276
501
  snapshot: RuntimeRenderSnapshot,
@@ -0,0 +1,72 @@
1
+ import type { CanonicalWorkbook } from "../model/workbook.ts";
2
+ import { listSheets } from "../model/workbook.ts";
3
+
4
+ const SHARED_STRINGS_NAMESPACE = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
5
+
6
+ export interface SharedStringTableSerialization {
7
+ xml: string;
8
+ strings: string[];
9
+ indexByValue: Map<string, number>;
10
+ totalCount: number;
11
+ }
12
+
13
+ export function buildSharedStringsTable(
14
+ workbook: CanonicalWorkbook,
15
+ ): SharedStringTableSerialization {
16
+ const strings: string[] = [];
17
+ const indexByValue = new Map<string, number>();
18
+ let totalCount = 0;
19
+
20
+ for (const sheet of listSheets(workbook)) {
21
+ for (const cell of sheet.cells.values()) {
22
+ if (cell.kind !== "text") {
23
+ continue;
24
+ }
25
+
26
+ totalCount += 1;
27
+
28
+ if (!indexByValue.has(cell.value)) {
29
+ indexByValue.set(cell.value, strings.length);
30
+ strings.push(cell.value);
31
+ }
32
+ }
33
+ }
34
+
35
+ return {
36
+ xml: serializeSharedStringsXml(strings, totalCount),
37
+ strings,
38
+ indexByValue,
39
+ totalCount,
40
+ };
41
+ }
42
+
43
+ export function serializeSharedStringsXml(
44
+ strings: readonly string[],
45
+ totalCount: number = strings.length,
46
+ ): string {
47
+ const items = strings.map(serializeSharedStringItem);
48
+ const body = items.length > 0 ? `\n${items.join("\n")}\n` : "";
49
+
50
+ return [
51
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
52
+ `<sst xmlns="${SHARED_STRINGS_NAMESPACE}" count="${totalCount}" uniqueCount="${strings.length}">${body}</sst>`,
53
+ ].join("\n");
54
+ }
55
+
56
+ function serializeSharedStringItem(value: string): string {
57
+ const preserveSpace = requiresPreservedSpace(value) ? ` xml:space="preserve"` : "";
58
+ return ` <si><t${preserveSpace}>${escapeXml(value)}</t></si>`;
59
+ }
60
+
61
+ function requiresPreservedSpace(value: string): boolean {
62
+ return /^\s/.test(value) || /\s$/.test(value);
63
+ }
64
+
65
+ function escapeXml(value: string): string {
66
+ return value
67
+ .replace(/&/g, "&amp;")
68
+ .replace(/"/g, "&quot;")
69
+ .replace(/</g, "&lt;")
70
+ .replace(/>/g, "&gt;")
71
+ .replace(/'/g, "&apos;");
72
+ }