@beyondwork/docx-react-component 1.0.18 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Parse styles.xml into the canonical StylesCatalog.
3
+ *
4
+ * Reads paragraph, character, and table style definitions plus latent-style
5
+ * metadata from the package styles part. This makes styles.xml the canonical
6
+ * source of style truth instead of synthesizing display names from referenced
7
+ * styleId values.
8
+ */
9
+
10
+ import type {
11
+ CharacterStyleDefinition,
12
+ LatentStyleDefinition,
13
+ ParagraphStyleDefinition,
14
+ StylesCatalog,
15
+ TableStyleDefinition,
16
+ } from "../../model/canonical-document.ts";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Inline XML node types (same pattern as parse-numbering.ts)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface XmlElementNode {
23
+ type: "element";
24
+ name: string;
25
+ attributes: Record<string, string>;
26
+ children: XmlNode[];
27
+ }
28
+
29
+ interface XmlTextNode {
30
+ type: "text";
31
+ text: string;
32
+ }
33
+
34
+ type XmlNode = XmlElementNode | XmlTextNode;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Public interface
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export interface ParseStylesResult {
41
+ catalog: StylesCatalog;
42
+ /** True when the catalog was parsed from actual styles.xml content. */
43
+ fromPackage: boolean;
44
+ /** Diagnostic notes for logging/proof. */
45
+ diagnostics: string[];
46
+ }
47
+
48
+ /**
49
+ * Parse a styles.xml string into a canonical StylesCatalog.
50
+ *
51
+ * Returns `fromPackage: false` when the input is empty or structurally empty,
52
+ * in which case the catalog fields will be empty records.
53
+ */
54
+ export function parseStylesXml(xml: string): ParseStylesResult {
55
+ const diagnostics: string[] = [];
56
+
57
+ if (!xml || xml.trim().length === 0) {
58
+ diagnostics.push("styles.xml is empty; synthetic fallback will be used");
59
+ return {
60
+ catalog: { paragraphs: {}, characters: {}, tables: {} },
61
+ fromPackage: false,
62
+ diagnostics,
63
+ };
64
+ }
65
+
66
+ let root: XmlElementNode;
67
+ try {
68
+ root = parseXml(xml);
69
+ } catch {
70
+ diagnostics.push("styles.xml could not be parsed; synthetic fallback will be used");
71
+ return {
72
+ catalog: { paragraphs: {}, characters: {}, tables: {} },
73
+ fromPackage: false,
74
+ diagnostics,
75
+ };
76
+ }
77
+
78
+ const stylesElement = findChildElementOptional(root, "styles");
79
+ if (!stylesElement) {
80
+ diagnostics.push("styles.xml has no <w:styles> root; synthetic fallback will be used");
81
+ return {
82
+ catalog: { paragraphs: {}, characters: {}, tables: {} },
83
+ fromPackage: false,
84
+ diagnostics,
85
+ };
86
+ }
87
+
88
+ const paragraphs: Record<string, ParagraphStyleDefinition> = {};
89
+ const characters: Record<string, CharacterStyleDefinition> = {};
90
+ const tables: Record<string, TableStyleDefinition> = {};
91
+ const latentStyles: Record<string, LatentStyleDefinition> = {};
92
+
93
+ for (const child of stylesElement.children) {
94
+ if (child.type !== "element") continue;
95
+ const local = localName(child.name);
96
+
97
+ if (local === "style") {
98
+ const styleType = child.attributes["w:type"] ?? child.attributes.type;
99
+ const styleId = child.attributes["w:styleId"] ?? child.attributes.styleId;
100
+ if (!styleId) continue;
101
+
102
+ const displayName = readStyleDisplayName(child) ?? styleId;
103
+ const basedOn = readLinkedStyleId(child, "basedOn");
104
+ const isDefault = (child.attributes["w:default"] ?? child.attributes.default) === "1";
105
+
106
+ switch (styleType) {
107
+ case "paragraph": {
108
+ const nextStyle = readLinkedStyleId(child, "next");
109
+ const outlineLevel = readParagraphStyleOutlineLevel(child);
110
+ paragraphs[styleId] = {
111
+ styleId,
112
+ displayName,
113
+ kind: "paragraph",
114
+ isDefault,
115
+ ...(basedOn ? { basedOn } : {}),
116
+ ...(nextStyle ? { nextStyle } : {}),
117
+ ...(outlineLevel !== undefined ? { outlineLevel } : {}),
118
+ };
119
+ break;
120
+ }
121
+ case "character": {
122
+ characters[styleId] = {
123
+ styleId,
124
+ displayName,
125
+ kind: "character",
126
+ isDefault,
127
+ ...(basedOn ? { basedOn } : {}),
128
+ };
129
+ break;
130
+ }
131
+ case "table": {
132
+ tables[styleId] = {
133
+ styleId,
134
+ displayName,
135
+ kind: "table",
136
+ isDefault,
137
+ ...(basedOn ? { basedOn } : {}),
138
+ };
139
+ break;
140
+ }
141
+ default:
142
+ // numbering/list styles are not part of the canonical catalog
143
+ break;
144
+ }
145
+ } else if (local === "latentStyles") {
146
+ readLatentStyles(child, latentStyles);
147
+ }
148
+ }
149
+
150
+ const hasLatent = Object.keys(latentStyles).length > 0;
151
+ diagnostics.push(
152
+ `parsed ${Object.keys(paragraphs).length} paragraph, ` +
153
+ `${Object.keys(characters).length} character, ` +
154
+ `${Object.keys(tables).length} table styles` +
155
+ (hasLatent ? `, ${Object.keys(latentStyles).length} latent styles` : ""),
156
+ );
157
+
158
+ return {
159
+ catalog: {
160
+ paragraphs,
161
+ characters,
162
+ tables,
163
+ ...(hasLatent ? { latentStyles } : {}),
164
+ },
165
+ fromPackage: true,
166
+ diagnostics,
167
+ };
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Helpers
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function readStyleDisplayName(styleNode: XmlElementNode): string | undefined {
175
+ const nameEl = findChildElementOptional(styleNode, "name");
176
+ if (!nameEl) return undefined;
177
+ return nameEl.attributes["w:val"] ?? nameEl.attributes.val ?? undefined;
178
+ }
179
+
180
+ function readLinkedStyleId(
181
+ styleNode: XmlElementNode,
182
+ elementLocalName: string,
183
+ ): string | undefined {
184
+ const el = findChildElementOptional(styleNode, elementLocalName);
185
+ if (!el) return undefined;
186
+ return el.attributes["w:val"] ?? el.attributes.val ?? undefined;
187
+ }
188
+
189
+ function readParagraphStyleOutlineLevel(
190
+ styleNode: XmlElementNode,
191
+ ): number | undefined {
192
+ const paragraphProperties = findChildElementOptional(styleNode, "pPr");
193
+ if (!paragraphProperties) {
194
+ return undefined;
195
+ }
196
+
197
+ const outlineLevel = findChildElementOptional(paragraphProperties, "outlineLvl");
198
+ if (!outlineLevel) {
199
+ return undefined;
200
+ }
201
+
202
+ const rawValue = outlineLevel.attributes["w:val"] ?? outlineLevel.attributes.val;
203
+ const parsed = rawValue !== undefined ? Number.parseInt(rawValue, 10) : Number.NaN;
204
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
205
+ }
206
+
207
+ function readLatentStyles(
208
+ latentNode: XmlElementNode,
209
+ out: Record<string, LatentStyleDefinition>,
210
+ ): void {
211
+ for (const child of latentNode.children) {
212
+ if (child.type !== "element" || localName(child.name) !== "lsdException") continue;
213
+
214
+ const name = child.attributes["w:name"] ?? child.attributes.name;
215
+ if (!name) continue;
216
+
217
+ const locked = readBoolAttr(child, "locked");
218
+ const semiHidden = readBoolAttr(child, "semiHidden");
219
+ const unhideWhenUsed = readBoolAttr(child, "unhideWhenUsed");
220
+ const qFormat = readBoolAttr(child, "qFormat");
221
+ const rawPriority = child.attributes["w:uiPriority"] ?? child.attributes.uiPriority;
222
+ const uiPriority = rawPriority !== undefined ? Number.parseInt(rawPriority, 10) : undefined;
223
+
224
+ out[name] = {
225
+ name,
226
+ ...(locked !== undefined ? { locked } : {}),
227
+ ...(semiHidden !== undefined ? { semiHidden } : {}),
228
+ ...(unhideWhenUsed !== undefined ? { unhideWhenUsed } : {}),
229
+ ...(qFormat !== undefined ? { qFormat } : {}),
230
+ ...(uiPriority !== undefined && !Number.isNaN(uiPriority) ? { uiPriority } : {}),
231
+ };
232
+ }
233
+ }
234
+
235
+ function readBoolAttr(node: XmlElementNode, attrLocalName: string): boolean | undefined {
236
+ const val = node.attributes[`w:${attrLocalName}`] ?? node.attributes[attrLocalName];
237
+ if (val === undefined) return undefined;
238
+ return val === "1" || val === "true";
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Inline XML parser (same implementation as parse-numbering.ts)
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function findChildElementOptional(
246
+ node: XmlElementNode,
247
+ childLocalName: string,
248
+ ): XmlElementNode | undefined {
249
+ return node.children.find(
250
+ (entry): entry is XmlElementNode =>
251
+ entry.type === "element" && localName(entry.name) === childLocalName,
252
+ );
253
+ }
254
+
255
+ function localName(name: string): string {
256
+ const separatorIndex = name.indexOf(":");
257
+ return separatorIndex >= 0 ? name.slice(separatorIndex + 1) : name;
258
+ }
259
+
260
+ function parseXml(xml: string): XmlElementNode {
261
+ const root: XmlElementNode = {
262
+ type: "element",
263
+ name: "__root__",
264
+ attributes: {},
265
+ children: [],
266
+ };
267
+ const stack: XmlElementNode[] = [root];
268
+ let cursor = 0;
269
+
270
+ while (cursor < xml.length) {
271
+ if (xml.startsWith("<!--", cursor)) {
272
+ const end = xml.indexOf("-->", cursor);
273
+ cursor = end >= 0 ? end + 3 : xml.length;
274
+ continue;
275
+ }
276
+
277
+ if (xml.startsWith("<?", cursor)) {
278
+ const end = xml.indexOf("?>", cursor);
279
+ cursor = end >= 0 ? end + 2 : xml.length;
280
+ continue;
281
+ }
282
+
283
+ if (xml.startsWith("<![CDATA[", cursor)) {
284
+ const end = xml.indexOf("]]>", cursor);
285
+ const textEnd = end >= 0 ? end : xml.length;
286
+ stack[stack.length - 1]?.children.push({
287
+ type: "text",
288
+ text: xml.slice(cursor + 9, textEnd),
289
+ });
290
+ cursor = end >= 0 ? end + 3 : xml.length;
291
+ continue;
292
+ }
293
+
294
+ if (xml[cursor] !== "<") {
295
+ const nextTag = xml.indexOf("<", cursor);
296
+ const end = nextTag >= 0 ? nextTag : xml.length;
297
+ const text = decodeXmlEntities(xml.slice(cursor, end));
298
+ if (text.length > 0) {
299
+ stack[stack.length - 1]?.children.push({
300
+ type: "text",
301
+ text,
302
+ });
303
+ }
304
+ cursor = end;
305
+ continue;
306
+ }
307
+
308
+ if (xml[cursor + 1] === "/") {
309
+ const end = xml.indexOf(">", cursor);
310
+ if (end < 0) {
311
+ throw new Error("Malformed XML: missing closing >.");
312
+ }
313
+
314
+ const name = xml.slice(cursor + 2, end).trim();
315
+ const current = stack.pop();
316
+ if (!current || localName(current.name) !== localName(name)) {
317
+ throw new Error(`Malformed XML: unexpected closing tag </${name}>.`);
318
+ }
319
+
320
+ cursor = end + 1;
321
+ continue;
322
+ }
323
+
324
+ const tagEnd = findTagEnd(xml, cursor);
325
+ const tagBody = xml.slice(cursor + 1, tagEnd);
326
+ const selfClosing = /\/\s*$/.test(tagBody);
327
+ const { name, attributes } = parseTag(tagBody.replace(/\/\s*$/, "").trim());
328
+ const element: XmlElementNode = {
329
+ type: "element",
330
+ name,
331
+ attributes,
332
+ children: [],
333
+ };
334
+ stack[stack.length - 1]?.children.push(element);
335
+
336
+ if (!selfClosing) {
337
+ stack.push(element);
338
+ }
339
+
340
+ cursor = tagEnd + 1;
341
+ }
342
+
343
+ if (stack.length !== 1) {
344
+ throw new Error("Malformed XML: unclosed element.");
345
+ }
346
+
347
+ return root;
348
+ }
349
+
350
+ function parseTag(tagBody: string): { name: string; attributes: Record<string, string> } {
351
+ let cursor = 0;
352
+ while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
353
+ cursor += 1;
354
+ }
355
+
356
+ const nameStart = cursor;
357
+ while (cursor < tagBody.length && !/\s/.test(tagBody[cursor] ?? "")) {
358
+ cursor += 1;
359
+ }
360
+
361
+ const name = tagBody.slice(nameStart, cursor);
362
+ const attributes: Record<string, string> = {};
363
+
364
+ while (cursor < tagBody.length) {
365
+ while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
366
+ cursor += 1;
367
+ }
368
+ if (cursor >= tagBody.length) {
369
+ break;
370
+ }
371
+
372
+ const keyStart = cursor;
373
+ while (cursor < tagBody.length && !/[\s=]/.test(tagBody[cursor] ?? "")) {
374
+ cursor += 1;
375
+ }
376
+ const key = tagBody.slice(keyStart, cursor);
377
+
378
+ while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
379
+ cursor += 1;
380
+ }
381
+
382
+ if (tagBody[cursor] !== "=") {
383
+ attributes[key] = "";
384
+ continue;
385
+ }
386
+ cursor += 1;
387
+
388
+ while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
389
+ cursor += 1;
390
+ }
391
+
392
+ const quote = tagBody[cursor];
393
+ if (quote !== `"` && quote !== `'`) {
394
+ throw new Error(`Malformed XML attribute ${key}.`);
395
+ }
396
+ cursor += 1;
397
+
398
+ const valueStart = cursor;
399
+ while (cursor < tagBody.length && tagBody[cursor] !== quote) {
400
+ cursor += 1;
401
+ }
402
+ const rawValue = tagBody.slice(valueStart, cursor);
403
+ attributes[key] = decodeXmlEntities(rawValue);
404
+ cursor += 1;
405
+ }
406
+
407
+ return { name, attributes };
408
+ }
409
+
410
+ function findTagEnd(xml: string, start: number): number {
411
+ let cursor = start + 1;
412
+ let quote: string | null = null;
413
+
414
+ while (cursor < xml.length) {
415
+ const current = xml[cursor];
416
+ if (quote) {
417
+ if (current === quote) {
418
+ quote = null;
419
+ }
420
+ cursor += 1;
421
+ continue;
422
+ }
423
+
424
+ if (current === `"` || current === `'`) {
425
+ quote = current;
426
+ cursor += 1;
427
+ continue;
428
+ }
429
+
430
+ if (current === ">") {
431
+ return cursor;
432
+ }
433
+
434
+ cursor += 1;
435
+ }
436
+
437
+ throw new Error("Malformed XML: missing >.");
438
+ }
439
+
440
+ function decodeXmlEntities(value: string): string {
441
+ return value.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (match, entity) => {
442
+ switch (entity) {
443
+ case "amp":
444
+ return "&";
445
+ case "lt":
446
+ return "<";
447
+ case "gt":
448
+ return ">";
449
+ case "quot":
450
+ return `"`;
451
+ case "apos":
452
+ return "'";
453
+ default:
454
+ if (entity.startsWith("#x")) {
455
+ return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
456
+ }
457
+ if (entity.startsWith("#")) {
458
+ return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
459
+ }
460
+ return match;
461
+ }
462
+ });
463
+ }
@@ -2,6 +2,7 @@ import type {
2
2
  ThemeColorScheme,
3
3
  ThemeDefinition,
4
4
  ThemeFontScheme,
5
+ ResolvedTheme,
5
6
  } from "../../model/canonical-document.ts";
6
7
 
7
8
  // ---- XML node types (inline, no external dep) ----
@@ -85,6 +86,37 @@ export function parseThemeXml(xml: string): ThemeDefinition {
85
86
  return result;
86
87
  }
87
88
 
89
+ /**
90
+ * Resolve a ThemeDefinition into flattened runtime theme inputs.
91
+ * Maps OOXML theme slot names to CSS-usable color values and font families.
92
+ */
93
+ export function resolveTheme(theme: ThemeDefinition): ResolvedTheme {
94
+ const colors: Record<string, string> = {};
95
+
96
+ if (theme.colorScheme?.colors) {
97
+ for (const [slot, value] of Object.entries(theme.colorScheme.colors)) {
98
+ colors[slot] = value;
99
+ }
100
+ }
101
+
102
+ return {
103
+ colors,
104
+ majorFont: theme.fontScheme?.majorFont,
105
+ minorFont: theme.fontScheme?.minorFont,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Resolve a theme color reference (e.g. "accent1", "dk1") to a CSS color string.
111
+ * Returns undefined if the theme does not contain the requested slot.
112
+ */
113
+ export function resolveThemeColor(
114
+ theme: ResolvedTheme | undefined,
115
+ colorSlot: string,
116
+ ): string | undefined {
117
+ return theme?.colors[colorSlot];
118
+ }
119
+
88
120
  // ---- Internal helpers ----
89
121
 
90
122
  function parseColorScheme(
@@ -4,6 +4,8 @@ import type {
4
4
  BookmarkStartNode,
5
5
  CanonicalDocument,
6
6
  DocumentNode,
7
+ FieldRegistry,
8
+ FieldRegistryEntry,
7
9
  } from "../model/canonical-document.ts";
8
10
 
9
11
  export interface LegalBookmark {
@@ -210,6 +212,48 @@ function compareBookmarks(left: LegalBookmark, right: LegalBookmark): number {
210
212
  );
211
213
  }
212
214
 
215
+ /**
216
+ * Build a lookup map from bookmark name to bookmarkId for the document.
217
+ * Used by the field refresh runtime to resolve REF/PAGEREF/NOTEREF targets.
218
+ */
219
+ export function buildBookmarkNameMap(
220
+ document: Pick<CanonicalDocument, "content"> | DocumentNode,
221
+ ): Map<string, { bookmarkId: string; paragraphIndex: number }> {
222
+ const root = "content" in document ? document.content : document;
223
+ const map = new Map<string, { bookmarkId: string; paragraphIndex: number }>();
224
+ let paragraphIndex = -1;
225
+
226
+ walkDocument(root, (node) => {
227
+ if (node.type === "paragraph") {
228
+ paragraphIndex += 1;
229
+ }
230
+ if (node.type === "bookmark_start" && node.name) {
231
+ map.set(node.name, { bookmarkId: node.bookmarkId, paragraphIndex });
232
+ }
233
+ });
234
+
235
+ return map;
236
+ }
237
+
238
+ /**
239
+ * Resolve field-to-bookmark dependencies from a field registry.
240
+ * Returns a map of bookmark names to the field entries that reference them.
241
+ * This enables the runtime to determine which fields need refresh when
242
+ * a bookmark's content changes.
243
+ */
244
+ export function resolveBookmarkFieldDependencies(
245
+ registry: FieldRegistry,
246
+ ): Map<string, FieldRegistryEntry[]> {
247
+ const deps = new Map<string, FieldRegistryEntry[]>();
248
+ for (const entry of registry.supported) {
249
+ if (!entry.fieldTarget) continue;
250
+ const existing = deps.get(entry.fieldTarget) ?? [];
251
+ existing.push(entry);
252
+ deps.set(entry.fieldTarget, existing);
253
+ }
254
+ return deps;
255
+ }
256
+
213
257
  function walkDocument(node: DocumentNode, visit: (node: DocumentNode) => void): void {
214
258
  visit(node);
215
259
 
@@ -7,8 +7,10 @@ import type {
7
7
  CanonicalDocument,
8
8
  DocumentNode,
9
9
  FieldNode,
10
+ FieldRegistry,
10
11
  HyperlinkNode,
11
12
  ParagraphNode,
13
+ TocEntry,
12
14
  } from "../model/canonical-document.ts";
13
15
 
14
16
  export interface CrossReferencePattern {
@@ -33,6 +35,40 @@ interface FieldReference {
33
35
  instruction: string;
34
36
  }
35
37
 
38
+ /**
39
+ * Collect field references from a canonical document, leveraging the
40
+ * field family classification when available.
41
+ */
42
+ export function collectFieldReferencesFromCanonicalDocument(
43
+ document: Pick<CanonicalDocument, "content"> | DocumentNode,
44
+ ): Array<{ family: string; target: string; instruction: string; paragraphIndex: number; displayText: string }> {
45
+ const root = "content" in document ? document.content : document;
46
+ const results: Array<{ family: string; target: string; instruction: string; paragraphIndex: number; displayText: string }> = [];
47
+ let paragraphIndex = -1;
48
+
49
+ walkDocument(root, (node) => {
50
+ if (node.type === "paragraph") {
51
+ paragraphIndex += 1;
52
+ for (const child of node.children) {
53
+ if (child.type === "field" && child.fieldFamily && child.fieldTarget) {
54
+ const family = child.fieldFamily;
55
+ if (family === "REF" || family === "PAGEREF" || family === "NOTEREF") {
56
+ results.push({
57
+ family,
58
+ target: child.fieldTarget,
59
+ instruction: child.instruction,
60
+ paragraphIndex,
61
+ displayText: flattenInlineText(child.children).trim(),
62
+ });
63
+ }
64
+ }
65
+ }
66
+ }
67
+ });
68
+
69
+ return results;
70
+ }
71
+
36
72
  const CROSS_REFERENCE_PATTERN =
37
73
  /\b(Section|Clause|Article|Schedule|Exhibit|Appendix)\s+(\d+(?:\.\d+)*|[A-Z]-\d+|[A-Z])(?=[^A-Za-z0-9]|$)/g;
38
74
  const W_REF_PATTERN = /<w:ref\b([^>]*)\/?>/g;
@@ -281,7 +317,7 @@ function flattenParagraphText(paragraph: ParagraphNode): string {
281
317
  case "hyperlink":
282
318
  return flattenInlineText(child.children);
283
319
  case "field":
284
- return flattenInlineText(child.children);
320
+ return child.children ? flattenInlineText(child.children) : "";
285
321
  case "tab":
286
322
  return "\t";
287
323
  case "hard_break":
@@ -335,6 +371,28 @@ function dedupeCrossReferences(references: CrossReference[]): CrossReference[] {
335
371
  return deduped;
336
372
  }
337
373
 
374
+ /**
375
+ * Collect TOC entries from a field registry's tocStructure.
376
+ * Returns the entries in document order with their heading text and levels.
377
+ * Returns an empty array if no TOC structure is available.
378
+ */
379
+ export function collectTocEntriesFromRegistry(
380
+ registry: FieldRegistry | undefined,
381
+ ): TocEntry[] {
382
+ return registry?.tocStructure?.entries ?? [];
383
+ }
384
+
385
+ /**
386
+ * Assess whether the TOC structure in a field registry is current
387
+ * with respect to the document's heading structure.
388
+ */
389
+ export function assessTocFreshness(
390
+ registry: FieldRegistry | undefined,
391
+ ): "current" | "stale" | "absent" {
392
+ if (!registry?.tocStructure) return "absent";
393
+ return registry.tocStructure.status;
394
+ }
395
+
338
396
  function walkDocument(node: DocumentNode, visit: (node: DocumentNode) => void): void {
339
397
  visit(node);
340
398