@beyondwork/docx-react-component 1.0.47 → 1.0.49

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 (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -3,7 +3,9 @@ import type { TransactionMapping } from "../selection/mapping.ts";
3
3
  import {
4
4
  cloneParagraphProperties,
5
5
  cloneStoryUnit,
6
+ countLogicalPositions,
6
7
  createPlainText,
8
+ logicalPositionToUnitIndex,
7
9
  parseTextStory,
8
10
  serializeTextStory,
9
11
  type ParagraphProperties,
@@ -11,7 +13,8 @@ import {
11
13
  type TextStory,
12
14
  } from "../schema/text-schema.ts";
13
15
  import { createEditorSurfaceSnapshot } from "../../runtime/surface-projection.ts";
14
- import type { DocumentRootNode, ParagraphNode, SdtNode, TableNode } from "../../model/canonical-document.ts";
16
+ import type { DocumentRootNode, ParagraphNode, SdtNode, TableNode, TextMark } from "../../model/canonical-document.ts";
17
+ import type { TextFormattingDirective } from "../../api/public-types.ts";
15
18
 
16
19
  export type TextInsertion =
17
20
  | {
@@ -36,6 +39,13 @@ export type TextTransactionIntent =
36
39
  to: number;
37
40
  };
38
41
  insertion: TextInsertion[];
42
+ /**
43
+ * I7 — optional formatting directive governing which `marks` the inserted text
44
+ * units carry. Resolved against `story` + `normalizedRange` inside
45
+ * `applyLinearTextTransaction`. `undefined` (or `{ mode: "paragraph-default" }`)
46
+ * preserves pre-I7 behavior (no inherited run marks).
47
+ */
48
+ formatting?: TextFormattingDirective;
39
49
  }
40
50
  | {
41
51
  type: "delete_backward";
@@ -115,14 +125,21 @@ function applyLinearTextTransaction(
115
125
  ): TextTransactionResult {
116
126
  const story = parseTextStory(document.content);
117
127
  const normalizedRange = resolveRange(selection, story.size, intent);
118
- const insertionUnits = createInsertionUnits(intent, story, normalizedRange.from);
128
+ const resolvedMarks = resolveMarksForInsertion(intent, story, normalizedRange);
129
+ const insertionUnits = createInsertionUnits(intent, story, normalizedRange.from, resolvedMarks);
119
130
 
120
- ensureEditableRange(story.units.slice(normalizedRange.from, normalizedRange.to));
131
+ // `normalizedRange.{from,to}` are logical positions (scope markers = 0 width,
132
+ // matching surface-projection). Translate to unit-array indices so scope
133
+ // marker units preserved at the boundary stay intact on either side.
134
+ const unitFrom = logicalPositionToUnitIndex(story.units, normalizedRange.from, "before");
135
+ const unitTo = logicalPositionToUnitIndex(story.units, normalizedRange.to, "after");
136
+
137
+ ensureEditableRange(story.units.slice(unitFrom, unitTo));
121
138
 
122
139
  const nextUnits = [
123
- ...story.units.slice(0, normalizedRange.from).map(cloneStoryUnit),
140
+ ...story.units.slice(0, unitFrom).map(cloneStoryUnit),
124
141
  ...insertionUnits.map(cloneStoryUnit),
125
- ...story.units.slice(normalizedRange.to).map(cloneStoryUnit),
142
+ ...story.units.slice(unitTo).map(cloneStoryUnit),
126
143
  ];
127
144
 
128
145
  const nextStory: TextStory = {
@@ -130,9 +147,13 @@ function applyLinearTextTransaction(
130
147
  units: normalizeStoryUnits(nextUnits),
131
148
  size: 0,
132
149
  };
133
- nextStory.size = nextStory.units.length;
150
+ nextStory.size = countLogicalPositions(nextStory.units);
134
151
 
135
- const caret = normalizedRange.from + insertionUnits.length;
152
+ // `normalizedRange.from` is the logical insertion point; count the logical
153
+ // positions added by `insertionUnits` (skipping any scope markers) to derive
154
+ // the post-insert caret.
155
+ const logicalInsertionSize = countLogicalPositions(insertionUnits);
156
+ const caret = normalizedRange.from + logicalInsertionSize;
136
157
 
137
158
  return {
138
159
  document: {
@@ -767,6 +788,7 @@ function createInsertionUnits(
767
788
  intent: TextTransactionIntent,
768
789
  story: TextStory,
769
790
  position: number,
791
+ marks: TextMark[] | undefined,
770
792
  ): StoryUnit[] {
771
793
  if (intent.type !== "replace") {
772
794
  return [];
@@ -780,6 +802,7 @@ function createInsertionUnits(
780
802
  return Array.from(entry.text).map<StoryUnit>((character) => ({
781
803
  kind: "text",
782
804
  value: character,
805
+ ...(marks && marks.length > 0 ? { marks: marks.map((mark) => ({ ...mark })) } : {}),
783
806
  }));
784
807
  case "tab":
785
808
  return [{ kind: "tab" }];
@@ -796,6 +819,79 @@ function createInsertionUnits(
796
819
  });
797
820
  }
798
821
 
822
+ /**
823
+ * I7 — given a formatting directive, produce the `marks` array that inserted text
824
+ * units should carry. Returns `undefined` for the paragraph-default path (no marks).
825
+ *
826
+ * - `paragraph-default` / no directive → `undefined`.
827
+ * - `explicit` → caller-supplied marks verbatim.
828
+ * - `match-replaced-range`:
829
+ * - If the range is collapsed, use the marks of the text unit immediately left of
830
+ * the caret (Word-matching behavior for empty-range inserts).
831
+ * - Otherwise walk text units in `[from, to)`. If every text unit shares the same
832
+ * marks (by type), use them. Mixed → fall back to paragraph-default (`undefined`).
833
+ */
834
+ function resolveMarksForInsertion(
835
+ intent: TextTransactionIntent,
836
+ story: TextStory,
837
+ range: { from: number; to: number },
838
+ ): TextMark[] | undefined {
839
+ if (intent.type !== "replace") {
840
+ return undefined;
841
+ }
842
+ const directive = intent.formatting;
843
+ if (!directive || directive.mode === "paragraph-default") {
844
+ return undefined;
845
+ }
846
+ if (directive.mode === "explicit") {
847
+ return directive.marks.map((mark) => ({ ...mark }));
848
+ }
849
+
850
+ // match-replaced-range
851
+ const unitFrom = logicalPositionToUnitIndex(story.units, range.from, "before");
852
+ const unitTo = logicalPositionToUnitIndex(story.units, range.to, "after");
853
+
854
+ if (range.from === range.to) {
855
+ // Empty range — inherit from the text unit immediately left of the caret.
856
+ for (let i = unitFrom - 1; i >= 0; i -= 1) {
857
+ const unit = story.units[i];
858
+ if (unit?.kind === "text") {
859
+ return unit.marks ? unit.marks.map((mark) => ({ ...mark })) : undefined;
860
+ }
861
+ if (unit?.kind === "paragraph_break") {
862
+ break;
863
+ }
864
+ }
865
+ return undefined;
866
+ }
867
+
868
+ const textUnits: Array<{ marks?: TextMark[] }> = [];
869
+ for (let i = unitFrom; i < unitTo; i += 1) {
870
+ const unit = story.units[i];
871
+ if (unit?.kind === "text") {
872
+ textUnits.push(unit);
873
+ }
874
+ }
875
+ if (textUnits.length === 0) {
876
+ return undefined;
877
+ }
878
+ const firstMarks = textUnits[0].marks;
879
+ const uniform = textUnits.every((unit) => marksAreEqual(firstMarks, unit.marks));
880
+ if (!uniform) {
881
+ return undefined;
882
+ }
883
+ return firstMarks ? firstMarks.map((mark) => ({ ...mark })) : undefined;
884
+ }
885
+
886
+ function marksAreEqual(left: TextMark[] | undefined, right: TextMark[] | undefined): boolean {
887
+ if (!left && !right) return true;
888
+ if (!left || !right) return false;
889
+ if (left.length !== right.length) return false;
890
+ const sortedLeft = [...left].map((mark) => mark.type).sort();
891
+ const sortedRight = [...right].map((mark) => mark.type).sort();
892
+ return sortedLeft.every((type, index) => type === sortedRight[index]);
893
+ }
894
+
799
895
  function resolveParagraphPropertiesAtPosition(
800
896
  story: TextStory,
801
897
  position: number,
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @internal HARNESS-ONLY debug-ports token module.
3
+ *
4
+ * This module is **not listed in `package.json#exports`**, so
5
+ * downstream consumers of `@beyondwork/docx-react-component` cannot
6
+ * import it via any public entry point. The in-repo harness at
7
+ * `services/react-word-editor/` reaches it via a relative path
8
+ * (`../../../src/internal/harness-debug-ports`); that is the only
9
+ * supported caller.
10
+ *
11
+ * ## Why this exists
12
+ *
13
+ * It replaces the former public `showUnsupportedObjectPreviews?: boolean`
14
+ * prop on `WordReviewEditorProps`, which regressed to `true` three
15
+ * times through merges (PRs #124, #131, #160). Each regression leaked
16
+ * preserve-only preview chrome (charts, SmartArt, shapes, WordArt,
17
+ * VML, "N preserve-only features detected" banner, lock-callouts)
18
+ * into consumer applications.
19
+ *
20
+ * ## What the new shape buys us
21
+ *
22
+ * 1. **Type-level**: the `HarnessDebugPorts` brand uses a `unique
23
+ * symbol` key that cannot be obtained outside this file, so
24
+ * downstream TypeScript consumers cannot structurally construct a
25
+ * valid token.
26
+ * 2. **Module-level**: this file is not in the package's export map.
27
+ * External consumers would have to reach into the installed
28
+ * package's internals via a non-guaranteed path to find it.
29
+ * 3. **Runtime gate**: even if a caller does manage to invoke
30
+ * `__createHarnessDebugPorts`, the factory checks for a harness
31
+ * environment marker (`globalThis.__DOCX_REACT_COMPONENT_HARNESS__`)
32
+ * that only the harness sets via `__markHarnessEnvironment()`.
33
+ * In a consumer app the flag is false; the returned token's
34
+ * permissions are all `false` regardless of input.
35
+ * 4. **Intent assertion**: the factory input requires a literal
36
+ * `confirmHarnessAuthor: "I-am-the-harness-dev-drawer"` string as
37
+ * a self-documenting "I know what I'm doing" gate.
38
+ *
39
+ * Flipping any of (1)–(4) is the regression this module is designed
40
+ * to prevent. The invariant test at
41
+ * `test/ui/unsupported-previews-invariant.test.ts` locks them all.
42
+ */
43
+
44
+ // NOT exported. The symbol's identity is what makes the brand
45
+ // unobtainable from outside this module: `typeof` on a const symbol
46
+ // gives TypeScript a `unique symbol` type that's only satisfiable
47
+ // by the same `const` binding. Consumers that don't import this
48
+ // module cannot satisfy the `[harnessDebugPortsBrand]: true` field.
49
+ const harnessDebugPortsBrand = Symbol("harnessDebugPortsBrand");
50
+
51
+ /**
52
+ * Opaque, brand-typed token passed through the internal
53
+ * `__harnessDebugPorts` editor prop. Cannot be constructed
54
+ * structurally — the brand key is a module-local `unique symbol`.
55
+ *
56
+ * @internal HARNESS-ONLY.
57
+ */
58
+ export interface HarnessDebugPorts {
59
+ readonly [harnessDebugPortsBrand]: true;
60
+ /**
61
+ * Sanitized. Only `true` when the token factory confirmed
62
+ * harness-env + debug-mode + literal author assertion.
63
+ */
64
+ readonly unsupportedObjectPreviews: boolean;
65
+ }
66
+
67
+ const HARNESS_GLOBAL_KEY = "__DOCX_REACT_COMPONENT_HARNESS__" as const;
68
+
69
+ /**
70
+ * Input to the harness-debug-ports token factory.
71
+ *
72
+ * @internal
73
+ */
74
+ export interface HarnessDebugPortsInput {
75
+ /**
76
+ * Self-documenting author gate. Must be the literal string
77
+ * `"I-am-the-harness-dev-drawer"`. Any other value fails the
78
+ * runtime check and the token's flags are all `false`.
79
+ */
80
+ readonly confirmHarnessAuthor: "I-am-the-harness-dev-drawer";
81
+ /**
82
+ * Harness dev-drawer debug-mode toggle. Even if `true`, the factory
83
+ * refuses to honor the request unless the harness environment is
84
+ * active (`__markHarnessEnvironment()` was called).
85
+ */
86
+ readonly debugMode: boolean;
87
+ /**
88
+ * Requested preview-rendering permission. Honored only when all
89
+ * runtime checks pass.
90
+ */
91
+ readonly unsupportedObjectPreviews: boolean;
92
+ }
93
+
94
+ /**
95
+ * Build a harness-debug-ports token.
96
+ *
97
+ * Runtime gate: the returned token's `unsupportedObjectPreviews`
98
+ * field is `true` **only** when all of the following hold:
99
+ *
100
+ * - `__markHarnessEnvironment()` was called previously (sets the
101
+ * `__DOCX_REACT_COMPONENT_HARNESS__` global to `true`);
102
+ * - `input.confirmHarnessAuthor === "I-am-the-harness-dev-drawer"`;
103
+ * - `input.debugMode === true`;
104
+ * - `input.unsupportedObjectPreviews === true`.
105
+ *
106
+ * Any check failure produces a fully-disabled token (same brand, all
107
+ * flags `false`). The editor component reads the token's flag
108
+ * through `readHarnessDebugPortsFlag()` so downstream rendering code
109
+ * sees a plain `boolean`.
110
+ *
111
+ * @internal HARNESS-ONLY.
112
+ */
113
+ export function __createHarnessDebugPorts(
114
+ input: HarnessDebugPortsInput,
115
+ ): HarnessDebugPorts {
116
+ const harnessActive =
117
+ typeof globalThis !== "undefined" &&
118
+ (globalThis as Record<string, unknown>)[HARNESS_GLOBAL_KEY] === true;
119
+ const authorConfirmed =
120
+ input.confirmHarnessAuthor === "I-am-the-harness-dev-drawer";
121
+ const allow =
122
+ harnessActive && authorConfirmed && input.debugMode === true;
123
+ return Object.freeze({
124
+ [harnessDebugPortsBrand]: true as const,
125
+ unsupportedObjectPreviews:
126
+ allow && input.unsupportedObjectPreviews === true,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Mark the current JavaScript realm as a harness. MUST be called
132
+ * once, synchronously, before the first editor render so the token
133
+ * factory can confirm the environment.
134
+ *
135
+ * @internal HARNESS-ONLY.
136
+ */
137
+ export function __markHarnessEnvironment(): void {
138
+ if (typeof globalThis !== "undefined") {
139
+ (globalThis as Record<string, unknown>)[HARNESS_GLOBAL_KEY] = true;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Read a sanitized flag from a token. Always returns `false` for
145
+ * `undefined` tokens (consumer code paths).
146
+ *
147
+ * @internal
148
+ */
149
+ export function readHarnessDebugPortsFlag(
150
+ token: HarnessDebugPorts | undefined,
151
+ flag: "unsupportedObjectPreviews",
152
+ ): boolean {
153
+ if (!token) return false;
154
+ return token[flag] === true;
155
+ }
156
+
157
+ /**
158
+ * Test-only: reset the harness environment flag. Exported so the
159
+ * invariant/factory tests can assert the "consumer app" path
160
+ * (factory called without `__markHarnessEnvironment`).
161
+ *
162
+ * @internal TEST-ONLY.
163
+ */
164
+ export function __resetHarnessEnvironmentForTests(): void {
165
+ if (typeof globalThis !== "undefined") {
166
+ delete (globalThis as Record<string, unknown>)[HARNESS_GLOBAL_KEY];
167
+ }
168
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  InlineNode,
25
25
  MediaItem,
26
26
  ParagraphNode,
27
+ ResolvedTheme,
27
28
  } from "../model/canonical-document.ts";
28
29
  import type {
29
30
  ChartPreviewResolveParams,
@@ -31,11 +32,34 @@ import type {
31
32
  } from "../api/public-types.ts";
32
33
  import type { OpcPackage } from "./opc/package-reader.ts";
33
34
  import { normalizePartPath, resolveRelationshipTarget } from "./ooxml/part-manifest.ts";
35
+ import { parseThemeXml, resolveTheme } from "./ooxml/parse-theme.ts";
36
+
37
+ /**
38
+ * Parse and resolve the workbook theme once, tolerating malformed XML.
39
+ * An unparseable theme must NOT abort the import — fall back to
40
+ * undefined so downstream renderers use the fallback palette.
41
+ */
42
+ function tryParseTheme(themeXml: string): ResolvedTheme | undefined {
43
+ try {
44
+ return resolveTheme(parseThemeXml(themeXml));
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
34
49
 
35
50
  interface ResolveContext {
36
51
  readonly package: OpcPackage;
37
52
  readonly adapter: EditorHostAdapter;
38
53
  readonly themeXml: string | undefined;
54
+ /**
55
+ * Once-per-import cache of the workbook theme. Stage 2 wiring:
56
+ * in-tree renderers consume `parsedTheme` via
57
+ * `composeSeriesColor(model, parsedTheme, seriesIdx)` to resolve a
58
+ * series palette entry into a concrete sRGB string without re-parsing
59
+ * theme XML on every render. Undefined when the document has no
60
+ * theme1.xml or when parseThemeXml threw.
61
+ */
62
+ readonly parsedTheme: ResolvedTheme | undefined;
39
63
  /** Monotonic counter so we can generate unique media ids across one run. */
40
64
  seq: number;
41
65
  }
@@ -63,8 +87,15 @@ export async function resolveChartPreviewsForDocument(
63
87
  if (pending.length === 0) return doc;
64
88
 
65
89
  const themeXml = extractPartTextFromPackage(pkg, "/word/theme/theme1.xml");
90
+ const parsedTheme = themeXml ? tryParseTheme(themeXml) : undefined;
66
91
  const renderer = adapter.renderChartPreview;
67
- const ctx: ResolveContext = { package: pkg, adapter, themeXml, seq: 0 };
92
+ const ctx: ResolveContext = {
93
+ package: pkg,
94
+ adapter,
95
+ themeXml,
96
+ parsedTheme,
97
+ seq: 0,
98
+ };
68
99
 
69
100
  const resolutions = await Promise.all(
70
101
  pending.map(async (entry) => {
@@ -214,6 +245,33 @@ function extractPartTextFromPackage(pkg: OpcPackage, path: string): string | und
214
245
  }
215
246
  }
216
247
 
248
+ /**
249
+ * Build a chart-part lookup callback suitable for
250
+ * `parseMainDocumentXml(..., chartPartLookup)`.
251
+ *
252
+ * The callback is called synchronously during parsing with a chart
253
+ * relationship id (the `r:id` on a `<c:chart>` reference). It resolves
254
+ * the id to a chart-part target path via the document's relationship
255
+ * table, then decodes the matching package part's bytes as UTF-8. Unknown
256
+ * ids and missing parts return undefined, in which case the parser
257
+ * proceeds without a typed `ChartModel` (the drawing still produces a
258
+ * `ChartPreviewNode` with `rawXml`).
259
+ */
260
+ export function createChartPartLookup(
261
+ pkg: OpcPackage,
262
+ documentPartPath: string,
263
+ documentRelationships: readonly import("./ooxml/part-manifest.ts").OpcRelationship[],
264
+ ): (rId: string) => string | undefined {
265
+ const relById = new Map(documentRelationships.map((r) => [r.id, r]));
266
+ return (rId: string): string | undefined => {
267
+ const rel = relById.get(rId);
268
+ if (!rel) return undefined;
269
+ const target = resolveRelationshipTarget(documentPartPath, rel);
270
+ if (!target) return undefined;
271
+ return extractPartTextFromPackage(pkg, normalizePartPath(target));
272
+ };
273
+ }
274
+
217
275
  /**
218
276
  * Produce a new CanonicalDocument with the resolved chart_preview
219
277
  * nodes carrying previewMediaId + corresponding MediaCatalog entries.