@beyondwork/docx-react-component 1.0.1 → 1.0.3

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 (172) hide show
  1. package/README.md +44 -104
  2. package/package.json +50 -30
  3. package/src/README.md +85 -0
  4. package/src/api/README.md +22 -0
  5. package/src/api/public-types.ts +525 -0
  6. package/src/compare/diff-engine.ts +530 -0
  7. package/src/compare/export-redlines.ts +162 -0
  8. package/src/compare/snapshot.ts +37 -0
  9. package/src/component-inventory.md +99 -0
  10. package/src/core/README.md +10 -0
  11. package/src/core/commands/README.md +3 -0
  12. package/src/core/commands/formatting-commands.ts +161 -0
  13. package/src/core/commands/image-commands.ts +144 -0
  14. package/src/core/commands/index.ts +1013 -0
  15. package/src/core/commands/list-commands.ts +370 -0
  16. package/src/core/commands/review-commands.ts +108 -0
  17. package/src/core/commands/text-commands.ts +119 -0
  18. package/src/core/schema/README.md +3 -0
  19. package/src/core/schema/text-schema.ts +512 -0
  20. package/src/core/selection/README.md +3 -0
  21. package/src/core/selection/mapping.ts +238 -0
  22. package/src/core/selection/review-anchors.ts +94 -0
  23. package/src/core/state/README.md +3 -0
  24. package/src/core/state/editor-state.ts +580 -0
  25. package/src/core/state/text-transaction.ts +276 -0
  26. package/src/formats/xlsx/io/parse-shared-strings.ts +41 -0
  27. package/src/formats/xlsx/io/parse-sheet.ts +289 -0
  28. package/src/formats/xlsx/io/parse-styles.ts +57 -0
  29. package/src/formats/xlsx/io/parse-workbook.ts +75 -0
  30. package/src/formats/xlsx/io/xlsx-session.ts +306 -0
  31. package/src/formats/xlsx/model/cell.ts +189 -0
  32. package/src/formats/xlsx/model/sheet.ts +244 -0
  33. package/src/formats/xlsx/model/styles.ts +118 -0
  34. package/src/formats/xlsx/model/workbook.ts +449 -0
  35. package/src/index.ts +45 -0
  36. package/src/io/README.md +10 -0
  37. package/src/io/docx-session.ts +1763 -0
  38. package/src/io/export/README.md +3 -0
  39. package/src/io/export/export-session.ts +165 -0
  40. package/src/io/export/minimal-docx.ts +115 -0
  41. package/src/io/export/reattach-preserved-parts.ts +54 -0
  42. package/src/io/export/serialize-comments.ts +876 -0
  43. package/src/io/export/serialize-footnotes.ts +217 -0
  44. package/src/io/export/serialize-headers-footers.ts +200 -0
  45. package/src/io/export/serialize-main-document.ts +982 -0
  46. package/src/io/export/serialize-numbering.ts +97 -0
  47. package/src/io/export/serialize-revisions.ts +389 -0
  48. package/src/io/export/serialize-runtime-revisions.ts +265 -0
  49. package/src/io/export/serialize-tables.ts +147 -0
  50. package/src/io/export/split-review-boundaries.ts +194 -0
  51. package/src/io/normalize/README.md +3 -0
  52. package/src/io/normalize/normalize-text.ts +437 -0
  53. package/src/io/ooxml/README.md +3 -0
  54. package/src/io/ooxml/parse-comments.ts +779 -0
  55. package/src/io/ooxml/parse-complex-content.ts +287 -0
  56. package/src/io/ooxml/parse-fields.ts +438 -0
  57. package/src/io/ooxml/parse-footnotes.ts +403 -0
  58. package/src/io/ooxml/parse-headers-footers.ts +483 -0
  59. package/src/io/ooxml/parse-inline-media.ts +431 -0
  60. package/src/io/ooxml/parse-main-document.ts +1846 -0
  61. package/src/io/ooxml/parse-numbering.ts +425 -0
  62. package/src/io/ooxml/parse-revisions.ts +658 -0
  63. package/src/io/ooxml/parse-shapes.ts +271 -0
  64. package/src/io/ooxml/parse-tables.ts +568 -0
  65. package/src/io/ooxml/parse-theme.ts +314 -0
  66. package/src/io/ooxml/part-manifest.ts +136 -0
  67. package/src/io/ooxml/revision-boundaries.ts +351 -0
  68. package/src/io/opc/README.md +3 -0
  69. package/src/io/opc/corrupt-package.ts +166 -0
  70. package/src/io/opc/docx-package.ts +74 -0
  71. package/src/io/opc/package-reader.ts +325 -0
  72. package/src/io/opc/package-writer.ts +273 -0
  73. package/src/legal/bookmarks.ts +196 -0
  74. package/src/legal/cross-references.ts +356 -0
  75. package/src/legal/defined-terms.ts +203 -0
  76. package/src/model/README.md +3 -0
  77. package/src/model/canonical-document.ts +1911 -0
  78. package/src/model/cds-1.0.0.ts +196 -0
  79. package/src/model/snapshot.ts +393 -0
  80. package/src/preservation/README.md +3 -0
  81. package/src/preservation/markup-compatibility.ts +48 -0
  82. package/src/preservation/opaque-fragment-store.ts +89 -0
  83. package/src/preservation/opaque-region.ts +233 -0
  84. package/src/preservation/package-preservation.ts +120 -0
  85. package/src/preservation/preserved-part-manifest.ts +56 -0
  86. package/src/preservation/relationship-retention.ts +57 -0
  87. package/src/preservation/store.ts +185 -0
  88. package/src/review/README.md +16 -0
  89. package/src/review/store/README.md +3 -0
  90. package/src/review/store/comment-anchors.ts +70 -0
  91. package/src/review/store/comment-remapping.ts +154 -0
  92. package/src/review/store/comment-store.ts +331 -0
  93. package/src/review/store/comment-thread.ts +109 -0
  94. package/src/review/store/revision-actions.ts +394 -0
  95. package/src/review/store/revision-store.ts +303 -0
  96. package/src/review/store/revision-types.ts +168 -0
  97. package/src/review/store/runtime-comment-store.ts +43 -0
  98. package/src/runtime/README.md +3 -0
  99. package/src/runtime/ai-action-policy.ts +764 -0
  100. package/src/runtime/document-runtime.ts +967 -0
  101. package/src/runtime/read-only-diagnostics-runtime.ts +232 -0
  102. package/src/runtime/review-runtime.ts +44 -0
  103. package/src/runtime/revision-runtime.ts +107 -0
  104. package/src/runtime/session-capabilities.ts +138 -0
  105. package/src/runtime/surface-projection.ts +570 -0
  106. package/src/runtime/table-commands.ts +87 -0
  107. package/src/runtime/table-schema.ts +140 -0
  108. package/src/runtime/virtualized-rendering.ts +258 -0
  109. package/src/ui/README.md +30 -0
  110. package/src/ui/WordReviewEditor.tsx +1506 -0
  111. package/src/ui/comments/README.md +3 -0
  112. package/src/ui/compatibility/README.md +3 -0
  113. package/src/ui/editor-surface/README.md +3 -0
  114. package/src/ui/headless/comment-decoration-model.ts +124 -0
  115. package/src/ui/headless/revision-decoration-model.ts +128 -0
  116. package/src/ui/headless/selection-helpers.ts +34 -0
  117. package/src/ui/headless/use-editor-keyboard.ts +98 -0
  118. package/src/ui/review/README.md +3 -0
  119. package/src/ui/shared/revision-filters.ts +31 -0
  120. package/src/ui/status/README.md +3 -0
  121. package/src/ui/theme/README.md +3 -0
  122. package/src/ui/toolbar/README.md +3 -0
  123. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +48 -0
  124. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +44 -0
  125. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +58 -0
  126. package/src/ui-tailwind/chrome/use-before-unload.ts +20 -0
  127. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +139 -0
  128. package/src/ui-tailwind/editor-surface/pm-decorations.ts +98 -0
  129. package/src/ui-tailwind/editor-surface/pm-position-map.ts +123 -0
  130. package/src/ui-tailwind/editor-surface/pm-schema.ts +452 -0
  131. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +327 -0
  132. package/src/ui-tailwind/editor-surface/search-plugin.ts +157 -0
  133. package/src/ui-tailwind/editor-surface/tw-caret.tsx +12 -0
  134. package/src/ui-tailwind/editor-surface/tw-editor-surface.tsx +150 -0
  135. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +118 -0
  136. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +52 -0
  137. package/src/ui-tailwind/editor-surface/tw-paragraph-block.tsx +151 -0
  138. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +215 -0
  139. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +111 -0
  140. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +122 -0
  141. package/src/ui-tailwind/index.ts +61 -0
  142. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +276 -0
  143. package/src/ui-tailwind/review/tw-health-panel.tsx +120 -0
  144. package/src/ui-tailwind/review/tw-review-rail.tsx +120 -0
  145. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +164 -0
  146. package/src/ui-tailwind/status/tw-status-bar.tsx +58 -0
  147. package/src/ui-tailwind/theme/editor-theme.css +190 -0
  148. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +48 -0
  149. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +231 -0
  150. package/src/ui-tailwind/tw-review-workspace.tsx +140 -0
  151. package/src/validation/README.md +3 -0
  152. package/src/validation/compatibility-engine.ts +317 -0
  153. package/src/validation/compatibility-report.ts +160 -0
  154. package/src/validation/diagnostics.ts +203 -0
  155. package/src/validation/import-diagnostics.ts +128 -0
  156. package/src/validation/low-priority-word-surfaces.ts +373 -0
  157. package/dist/chunk-32W6IVQE.js +0 -7725
  158. package/dist/chunk-32W6IVQE.js.map +0 -1
  159. package/dist/index.cjs +0 -23722
  160. package/dist/index.cjs.map +0 -1
  161. package/dist/index.d.cts +0 -7
  162. package/dist/index.d.ts +0 -7
  163. package/dist/index.js +0 -16011
  164. package/dist/index.js.map +0 -1
  165. package/dist/public-types-DqCURAz8.d.cts +0 -1152
  166. package/dist/public-types-DqCURAz8.d.ts +0 -1152
  167. package/dist/tailwind.cjs +0 -8295
  168. package/dist/tailwind.cjs.map +0 -1
  169. package/dist/tailwind.d.cts +0 -323
  170. package/dist/tailwind.d.ts +0 -323
  171. package/dist/tailwind.js +0 -553
  172. package/dist/tailwind.js.map +0 -1
@@ -0,0 +1,276 @@
1
+ import { createSelectionSnapshot, type CanonicalDocumentEnvelope, type SelectionSnapshot } from "./editor-state.ts";
2
+ import type { TransactionMapping } from "../selection/mapping.ts";
3
+ import {
4
+ cloneParagraphProperties,
5
+ cloneStoryUnit,
6
+ createPlainText,
7
+ parseTextStory,
8
+ serializeTextStory,
9
+ type ParagraphProperties,
10
+ type StoryUnit,
11
+ type TextStory,
12
+ } from "../schema/text-schema.ts";
13
+
14
+ export type TextInsertion =
15
+ | {
16
+ type: "text";
17
+ text: string;
18
+ }
19
+ | {
20
+ type: "tab";
21
+ }
22
+ | {
23
+ type: "hard_break";
24
+ }
25
+ | {
26
+ type: "paragraph_break";
27
+ };
28
+
29
+ export type TextTransactionIntent =
30
+ | {
31
+ type: "replace";
32
+ range?: {
33
+ from: number;
34
+ to: number;
35
+ };
36
+ insertion: TextInsertion[];
37
+ }
38
+ | {
39
+ type: "delete_backward";
40
+ }
41
+ | {
42
+ type: "delete_forward";
43
+ };
44
+
45
+ export interface TextTransactionResult {
46
+ document: CanonicalDocumentEnvelope;
47
+ selection: SelectionSnapshot;
48
+ mapping: TransactionMapping;
49
+ storyText: string;
50
+ }
51
+
52
+ export class TextTransactionError extends Error {
53
+ readonly code: "invalid_selection" | "unsupported_content";
54
+
55
+ constructor(
56
+ code: TextTransactionError["code"],
57
+ message: string,
58
+ ) {
59
+ super(message);
60
+ this.name = "TextTransactionError";
61
+ this.code = code;
62
+ }
63
+ }
64
+
65
+ export function applyTextTransaction(
66
+ document: CanonicalDocumentEnvelope,
67
+ selection: SelectionSnapshot,
68
+ intent: TextTransactionIntent,
69
+ options: {
70
+ timestamp: string;
71
+ },
72
+ ): TextTransactionResult {
73
+ const story = parseTextStory(document.content);
74
+ const normalizedRange = resolveRange(selection, story.size, intent);
75
+ const insertionUnits = createInsertionUnits(intent, story, normalizedRange.from);
76
+
77
+ ensureEditableRange(story.units.slice(normalizedRange.from, normalizedRange.to));
78
+
79
+ const nextUnits = [
80
+ ...story.units.slice(0, normalizedRange.from).map(cloneStoryUnit),
81
+ ...insertionUnits.map(cloneStoryUnit),
82
+ ...story.units.slice(normalizedRange.to).map(cloneStoryUnit),
83
+ ];
84
+
85
+ const nextStory: TextStory = {
86
+ firstParagraph: cloneParagraphProperties(story.firstParagraph),
87
+ units: normalizeStoryUnits(nextUnits),
88
+ size: 0,
89
+ };
90
+ nextStory.size = nextStory.units.length;
91
+
92
+ const caret = normalizedRange.from + insertionUnits.length;
93
+
94
+ return {
95
+ document: {
96
+ ...document,
97
+ updatedAt: options.timestamp,
98
+ content: serializeTextStory(nextStory),
99
+ },
100
+ selection: createSelectionSnapshot(caret, caret),
101
+ mapping: {
102
+ steps: [
103
+ {
104
+ from: normalizedRange.from,
105
+ to: normalizedRange.to,
106
+ insertSize: insertionUnits.length,
107
+ },
108
+ ],
109
+ metadata: {
110
+ affectsComments: true,
111
+ affectsRevisions: true,
112
+ ...(containsParagraphBoundaryChange(story, normalizedRange, insertionUnits)
113
+ ? { invalidatesStructures: true }
114
+ : {}),
115
+ },
116
+ },
117
+ storyText: createPlainText(nextStory),
118
+ };
119
+ }
120
+
121
+ function resolveRange(
122
+ selection: SelectionSnapshot,
123
+ storySize: number,
124
+ intent: TextTransactionIntent,
125
+ ): { from: number; to: number } {
126
+ const from = Math.max(0, Math.min(selection.anchor, selection.head));
127
+ const to = Math.max(0, Math.max(selection.anchor, selection.head));
128
+
129
+ if (from > storySize || to > storySize) {
130
+ throw new TextTransactionError(
131
+ "invalid_selection",
132
+ `Selection ${from}-${to} exceeds story size ${storySize}.`,
133
+ );
134
+ }
135
+
136
+ if (intent.type === "replace") {
137
+ if (intent.range) {
138
+ const explicitFrom = Math.max(0, Math.min(intent.range.from, intent.range.to));
139
+ const explicitTo = Math.max(0, Math.max(intent.range.from, intent.range.to));
140
+ if (explicitFrom > storySize || explicitTo > storySize) {
141
+ throw new TextTransactionError(
142
+ "invalid_selection",
143
+ `Explicit range ${explicitFrom}-${explicitTo} exceeds story size ${storySize}.`,
144
+ );
145
+ }
146
+
147
+ return {
148
+ from: explicitFrom,
149
+ to: explicitTo,
150
+ };
151
+ }
152
+
153
+ return { from, to };
154
+ }
155
+
156
+ if (from !== to) {
157
+ return { from, to };
158
+ }
159
+
160
+ if (intent.type === "delete_backward") {
161
+ return {
162
+ from: Math.max(0, from - 1),
163
+ to: from,
164
+ };
165
+ }
166
+
167
+ return {
168
+ from,
169
+ to: Math.min(storySize, from + 1),
170
+ };
171
+ }
172
+
173
+ function createInsertionUnits(
174
+ intent: TextTransactionIntent,
175
+ story: TextStory,
176
+ position: number,
177
+ ): StoryUnit[] {
178
+ if (intent.type !== "replace") {
179
+ return [];
180
+ }
181
+
182
+ const inheritedProps = resolveParagraphPropertiesAtPosition(story, position);
183
+
184
+ return intent.insertion.flatMap((entry) => {
185
+ switch (entry.type) {
186
+ case "text":
187
+ return Array.from(entry.text).map<StoryUnit>((character) => ({
188
+ kind: "text",
189
+ value: character,
190
+ }));
191
+ case "tab":
192
+ return [{ kind: "tab" }];
193
+ case "hard_break":
194
+ return [{ kind: "hard_break" }];
195
+ case "paragraph_break":
196
+ return [
197
+ {
198
+ kind: "paragraph_break",
199
+ nextParagraph: cloneParagraphProperties(inheritedProps),
200
+ },
201
+ ];
202
+ }
203
+ });
204
+ }
205
+
206
+ function resolveParagraphPropertiesAtPosition(
207
+ story: TextStory,
208
+ position: number,
209
+ ): ParagraphProperties {
210
+ let current = cloneParagraphProperties(story.firstParagraph);
211
+
212
+ for (let index = 0; index < Math.min(position, story.units.length); index += 1) {
213
+ const unit = story.units[index];
214
+ if (unit.kind === "paragraph_break") {
215
+ current = cloneParagraphProperties(unit.nextParagraph);
216
+ } else if (unit.kind === "opaque_block" && unit.nextParagraph) {
217
+ current = cloneParagraphProperties(unit.nextParagraph);
218
+ }
219
+ }
220
+
221
+ return current;
222
+ }
223
+
224
+ function normalizeStoryUnits(units: StoryUnit[]): StoryUnit[] {
225
+ if (units.length === 0) {
226
+ return [];
227
+ }
228
+
229
+ const normalized: StoryUnit[] = [];
230
+
231
+ for (const unit of units) {
232
+ if (
233
+ unit.kind === "paragraph_break" &&
234
+ normalized[normalized.length - 1]?.kind === "paragraph_break"
235
+ ) {
236
+ normalized.push({
237
+ kind: "paragraph_break",
238
+ nextParagraph: cloneParagraphProperties(unit.nextParagraph),
239
+ });
240
+ continue;
241
+ }
242
+
243
+ normalized.push(cloneStoryUnit(unit));
244
+ }
245
+
246
+ return normalized;
247
+ }
248
+
249
+ function ensureEditableRange(units: StoryUnit[]): void {
250
+ const protectedUnit = units.find(
251
+ (unit) => unit.kind === "opaque_inline" || unit.kind === "opaque_block" || unit.kind === "image",
252
+ );
253
+
254
+ if (!protectedUnit) {
255
+ return;
256
+ }
257
+
258
+ throw new TextTransactionError(
259
+ "unsupported_content",
260
+ `Text transaction crosses protected ${protectedUnit.kind} content.`,
261
+ );
262
+ }
263
+
264
+ function containsParagraphBoundaryChange(
265
+ story: TextStory,
266
+ range: { from: number; to: number },
267
+ insertionUnits: StoryUnit[],
268
+ ): boolean {
269
+ if (insertionUnits.some((unit) => unit.kind === "paragraph_break")) {
270
+ return true;
271
+ }
272
+
273
+ return story.units
274
+ .slice(range.from, range.to)
275
+ .some((unit) => unit.kind === "paragraph_break");
276
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Parses xl/sharedStrings.xml and returns the shared string table as an ordered array.
3
+ *
4
+ * Handles both simple text (<si><t>...</t></si>) and rich text runs
5
+ * (<si><r><t>...</t></r></si>) by concatenating all <t> content within each <si>.
6
+ */
7
+ export function parseSharedStringsXml(xml: string): string[] {
8
+ const result: string[] = [];
9
+ const siPattern = /<si>([\s\S]*?)<\/si>/g;
10
+ let siMatch: RegExpExecArray | null;
11
+
12
+ while ((siMatch = siPattern.exec(xml)) !== null) {
13
+ const siContent = siMatch[1] ?? "";
14
+ result.push(extractSharedStringText(siContent));
15
+ }
16
+
17
+ return result;
18
+ }
19
+
20
+ function extractSharedStringText(siContent: string): string {
21
+ // Collect text from all <t> elements (handles plain and rich text runs).
22
+ // The xml:space="preserve" attribute is not significant for the string value.
23
+ const tPattern = /<t(?:\s[^>]*)?>([^<]*)<\/t>/g;
24
+ const parts: string[] = [];
25
+ let tMatch: RegExpExecArray | null;
26
+
27
+ while ((tMatch = tPattern.exec(siContent)) !== null) {
28
+ parts.push(decodeXmlEntities(tMatch[1] ?? ""));
29
+ }
30
+
31
+ return parts.join("");
32
+ }
33
+
34
+ export function decodeXmlEntities(value: string): string {
35
+ return value
36
+ .replace(/&quot;/g, '"')
37
+ .replace(/&apos;/g, "'")
38
+ .replace(/&lt;/g, "<")
39
+ .replace(/&gt;/g, ">")
40
+ .replace(/&amp;/g, "&");
41
+ }
@@ -0,0 +1,289 @@
1
+ import { decodeXmlEntities, parseSharedStringsXml as _unused } from "./parse-shared-strings.ts";
2
+ import { parseXmlAttributes } from "./parse-styles.ts";
3
+
4
+ // Re-export for external use
5
+ export { decodeXmlEntities };
6
+
7
+ /**
8
+ * A parsed cell from a SpreadsheetML worksheet.
9
+ *
10
+ * Row and col are zero-based indices. The value discriminated union covers all
11
+ * SpreadsheetML cell type codes: s (shared string), inlineStr, n/absent (number),
12
+ * b (boolean), e (error), str (formula string result), and blank.
13
+ */
14
+ export type XlsxParsedCellValue =
15
+ | { type: "blank" }
16
+ | { type: "text"; value: string }
17
+ | { type: "number"; value: number }
18
+ | { type: "boolean"; value: boolean }
19
+ | { type: "formula"; formula: string; cachedValue: string | null }
20
+ | { type: "error"; errorCode: string };
21
+
22
+ export interface XlsxParsedCell {
23
+ row: number;
24
+ col: number;
25
+ value: XlsxParsedCellValue;
26
+ styleIndex: number | null;
27
+ }
28
+
29
+ /**
30
+ * A parsed merge range from the <mergeCells> section.
31
+ * All indices are zero-based.
32
+ */
33
+ export interface XlsxParsedMerge {
34
+ startRow: number;
35
+ startCol: number;
36
+ endRow: number;
37
+ endCol: number;
38
+ }
39
+
40
+ export interface SheetParseResult {
41
+ cells: XlsxParsedCell[];
42
+ merges: XlsxParsedMerge[];
43
+ }
44
+
45
+ /**
46
+ * Parses xl/worksheets/sheetN.xml.
47
+ *
48
+ * @param xml - Raw XML string of the worksheet part.
49
+ * @param sharedStrings - The shared string table from parseSharedStringsXml.
50
+ */
51
+ export function parseSheetXml(
52
+ xml: string,
53
+ sharedStrings: readonly string[],
54
+ ): SheetParseResult {
55
+ const cells: XlsxParsedCell[] = [];
56
+ const merges: XlsxParsedMerge[] = [];
57
+
58
+ const sheetDataMatch = /<sheetData>([\s\S]*?)<\/sheetData>/i.exec(xml);
59
+ if (sheetDataMatch) {
60
+ parseSheetData(sheetDataMatch[1] ?? "", sharedStrings, cells);
61
+ }
62
+
63
+ const mergeCellsMatch = /<mergeCells\b[^>]*>([\s\S]*?)<\/mergeCells>/i.exec(xml);
64
+ if (mergeCellsMatch) {
65
+ parseMergeCells(mergeCellsMatch[1] ?? "", merges);
66
+ }
67
+
68
+ return { cells, merges };
69
+ }
70
+
71
+ function parseSheetData(
72
+ sheetDataXml: string,
73
+ sharedStrings: readonly string[],
74
+ out: XlsxParsedCell[],
75
+ ): void {
76
+ // Match non-empty rows (<row ...>...</row>)
77
+ const rowPattern = /<row\b([^>]*)>([\s\S]*?)<\/row>/g;
78
+ let rowMatch: RegExpExecArray | null;
79
+
80
+ while ((rowMatch = rowPattern.exec(sheetDataXml)) !== null) {
81
+ const rowContent = rowMatch[2] ?? "";
82
+ parseRowCells(rowContent, sharedStrings, out);
83
+ }
84
+ }
85
+
86
+ function parseRowCells(
87
+ rowXml: string,
88
+ sharedStrings: readonly string[],
89
+ out: XlsxParsedCell[],
90
+ ): void {
91
+ // Match cells: both non-empty (<c ...>...</c>) and self-closing (<c .../>)
92
+ const cellPattern = /<c\b([^>]*)(?:>([\s\S]*?)<\/c>|\/>)/g;
93
+ let cellMatch: RegExpExecArray | null;
94
+
95
+ while ((cellMatch = cellPattern.exec(rowXml)) !== null) {
96
+ const attrs = parseXmlAttributes(cellMatch[1] ?? "");
97
+ const content = cellMatch[2] ?? "";
98
+ const cell = parseCell(attrs, content, sharedStrings);
99
+ if (cell !== null) {
100
+ out.push(cell);
101
+ }
102
+ }
103
+ }
104
+
105
+ function parseCell(
106
+ attrs: Record<string, string>,
107
+ content: string,
108
+ sharedStrings: readonly string[],
109
+ ): XlsxParsedCell | null {
110
+ const ref = attrs["r"];
111
+ if (!ref) {
112
+ return null;
113
+ }
114
+
115
+ const cellRef = parseCellRef(ref);
116
+ if (cellRef === null) {
117
+ return null;
118
+ }
119
+
120
+ const styleIndexAttr = attrs["s"];
121
+ const styleIndex =
122
+ styleIndexAttr !== undefined ? parseInt(styleIndexAttr, 10) : null;
123
+
124
+ const typeCode = attrs["t"] ?? "n";
125
+ const rawValue = extractTextContent(content, "v");
126
+ const formulaText = extractTextContent(content, "f");
127
+ const inlineText = extractInlineStringText(content);
128
+
129
+ const value = resolveCellValue(typeCode, rawValue, formulaText, inlineText, sharedStrings);
130
+
131
+ return {
132
+ row: cellRef.row,
133
+ col: cellRef.col,
134
+ value,
135
+ styleIndex: styleIndex !== null && !Number.isNaN(styleIndex) ? styleIndex : null,
136
+ };
137
+ }
138
+
139
+ function resolveCellValue(
140
+ typeCode: string,
141
+ rawValue: string | null,
142
+ formulaText: string | null,
143
+ inlineText: string | null,
144
+ sharedStrings: readonly string[],
145
+ ): XlsxParsedCellValue {
146
+ // Formula cells may have any result type; store formula + cached value.
147
+ if (formulaText !== null) {
148
+ return {
149
+ type: "formula",
150
+ formula: decodeXmlEntities(formulaText),
151
+ cachedValue: rawValue !== null ? decodeXmlEntities(rawValue) : null,
152
+ };
153
+ }
154
+
155
+ switch (typeCode) {
156
+ case "s": {
157
+ // Shared string reference
158
+ const index = rawValue !== null ? parseInt(rawValue, 10) : NaN;
159
+ const text =
160
+ !Number.isNaN(index) && index >= 0 && index < sharedStrings.length
161
+ ? (sharedStrings[index] ?? "")
162
+ : "";
163
+ return { type: "text", value: text };
164
+ }
165
+
166
+ case "inlineStr": {
167
+ return { type: "text", value: inlineText ?? "" };
168
+ }
169
+
170
+ case "str": {
171
+ // Formula result is a string; formula text would have been captured above.
172
+ // Here the cell carries only the cached string value (no formula tag).
173
+ return {
174
+ type: "text",
175
+ value: rawValue !== null ? decodeXmlEntities(rawValue) : "",
176
+ };
177
+ }
178
+
179
+ case "b": {
180
+ return { type: "boolean", value: rawValue === "1" };
181
+ }
182
+
183
+ case "e": {
184
+ return {
185
+ type: "error",
186
+ errorCode:
187
+ rawValue !== null ? decodeXmlEntities(rawValue) : "#ERROR!",
188
+ };
189
+ }
190
+
191
+ default: {
192
+ // "n" or absent — numeric value
193
+ if (rawValue === null || rawValue === "") {
194
+ return { type: "blank" };
195
+ }
196
+
197
+ const numericValue = parseFloat(rawValue);
198
+ return Number.isNaN(numericValue)
199
+ ? { type: "blank" }
200
+ : { type: "number", value: numericValue };
201
+ }
202
+ }
203
+ }
204
+
205
+ function extractTextContent(xml: string, tagName: string): string | null {
206
+ const pattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>([^<]*)</${tagName}>`, "i");
207
+ const match = pattern.exec(xml);
208
+ return match ? (match[1] ?? null) : null;
209
+ }
210
+
211
+ function extractInlineStringText(cellContent: string): string | null {
212
+ // <is> contains rich text identical to shared string runs
213
+ const isMatch = /<is>([\s\S]*?)<\/is>/i.exec(cellContent);
214
+ if (!isMatch) {
215
+ return null;
216
+ }
217
+
218
+ const tPattern = /<t(?:\s[^>]*)?>([^<]*)<\/t>/g;
219
+ const parts: string[] = [];
220
+ let tMatch: RegExpExecArray | null;
221
+
222
+ while ((tMatch = tPattern.exec(isMatch[1] ?? "")) !== null) {
223
+ parts.push(decodeXmlEntities(tMatch[1] ?? ""));
224
+ }
225
+
226
+ return parts.join("");
227
+ }
228
+
229
+ function parseMergeCells(mergeCellsXml: string, out: XlsxParsedMerge[]): void {
230
+ const mergePattern = /<mergeCell\b([^>]*?)(?:\/>|>[\s\S]*?<\/mergeCell>)/g;
231
+ let match: RegExpExecArray | null;
232
+
233
+ while ((match = mergePattern.exec(mergeCellsXml)) !== null) {
234
+ const attrs = parseXmlAttributes(match[1] ?? "");
235
+ const ref = attrs["ref"] ?? "";
236
+ const merge = parseMergeRef(ref);
237
+ if (merge !== null) {
238
+ out.push(merge);
239
+ }
240
+ }
241
+ }
242
+
243
+ function parseMergeRef(ref: string): XlsxParsedMerge | null {
244
+ const parts = ref.split(":");
245
+ if (parts.length !== 2) {
246
+ return null;
247
+ }
248
+
249
+ const start = parseCellRef(parts[0] ?? "");
250
+ const end = parseCellRef(parts[1] ?? "");
251
+ if (start === null || end === null) {
252
+ return null;
253
+ }
254
+
255
+ return {
256
+ startRow: start.row,
257
+ startCol: start.col,
258
+ endRow: end.row,
259
+ endCol: end.col,
260
+ };
261
+ }
262
+
263
+ function parseCellRef(ref: string): { row: number; col: number } | null {
264
+ const match = /^([A-Z]+)([0-9]+)$/i.exec(ref.trim());
265
+ if (!match) {
266
+ return null;
267
+ }
268
+
269
+ return {
270
+ col: colLettersToIndex(match[1] ?? ""),
271
+ row: parseInt(match[2] ?? "1", 10) - 1,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Converts a column letter sequence to a zero-based column index.
277
+ * "A" -> 0, "Z" -> 25, "AA" -> 26, "AZ" -> 51, "BA" -> 52.
278
+ */
279
+ function colLettersToIndex(letters: string): number {
280
+ let result = 0;
281
+
282
+ for (const char of letters.toUpperCase()) {
283
+ result = result * 26 + (char.charCodeAt(0) - 64);
284
+ }
285
+
286
+ return result - 1;
287
+ }
288
+
289
+ export { parseCellRef, colLettersToIndex };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Parses xl/styles.xml for the cellXfs table (cell format entries).
3
+ *
4
+ * Each entry provides style references (numFmtId, fontId, fillId, borderId)
5
+ * that cells reference by zero-based index via the s= attribute.
6
+ */
7
+ export interface XlsxStyleEntry {
8
+ numFmtId: number;
9
+ fontId: number;
10
+ fillId: number;
11
+ borderId: number;
12
+ }
13
+
14
+ export function parseStylesXml(xml: string): XlsxStyleEntry[] {
15
+ const cellXfsMatch = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/i.exec(xml);
16
+ if (!cellXfsMatch) {
17
+ return [];
18
+ }
19
+
20
+ const cellXfsContent = cellXfsMatch[1] ?? "";
21
+ const xfPattern = /<xf\b([^>]*?)(?:\/>|>[\s\S]*?<\/xf>)/g;
22
+ const entries: XlsxStyleEntry[] = [];
23
+ let xfMatch: RegExpExecArray | null;
24
+
25
+ while ((xfMatch = xfPattern.exec(cellXfsContent)) !== null) {
26
+ const attrs = parseXmlAttributes(xfMatch[1] ?? "");
27
+ entries.push({
28
+ numFmtId: parseIntAttr(attrs["numFmtId"], 0),
29
+ fontId: parseIntAttr(attrs["fontId"], 0),
30
+ fillId: parseIntAttr(attrs["fillId"], 0),
31
+ borderId: parseIntAttr(attrs["borderId"], 0),
32
+ });
33
+ }
34
+
35
+ return entries;
36
+ }
37
+
38
+ function parseIntAttr(value: string | undefined, defaultValue: number): number {
39
+ if (value === undefined || value === "") {
40
+ return defaultValue;
41
+ }
42
+
43
+ const parsed = parseInt(value, 10);
44
+ return Number.isNaN(parsed) ? defaultValue : parsed;
45
+ }
46
+
47
+ export function parseXmlAttributes(rawAttributes: string): Record<string, string> {
48
+ const attributes: Record<string, string> = {};
49
+ const attributePattern = /([A-Za-z_][\w:.-]*)="([^"]*)"/g;
50
+ let match: RegExpExecArray | null;
51
+
52
+ while ((match = attributePattern.exec(rawAttributes)) !== null) {
53
+ attributes[match[1]] = match[2];
54
+ }
55
+
56
+ return attributes;
57
+ }