@beyondwork/docx-react-component 1.0.54 → 1.0.56

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.54",
4
+ "version": "1.0.56",
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"
@@ -92,6 +93,37 @@
92
93
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css"
93
94
  },
94
95
  "types": "./src/index.ts",
96
+ "scripts": {
97
+ "build": "tsup",
98
+ "test": "bash scripts/run-workspace-tests.sh",
99
+ "test:repo": "node scripts/ci-check-layout-engine-version.mjs && node scripts/run-repo-tests.mjs core",
100
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
101
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
102
+ "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
103
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
104
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
105
+ "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
106
+ "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
107
+ "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
108
+ "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
109
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
110
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
111
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
112
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
113
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
114
+ "generate:token-reference": "node scripts/generate-token-reference.mjs",
115
+ "check:token-reference": "node scripts/generate-token-reference.mjs --check",
116
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
117
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
118
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
119
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
120
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
121
+ "wave:status": "bash scripts/wave-status.sh",
122
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
123
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
124
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
125
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
126
+ },
95
127
  "keywords": [
96
128
  "docx",
97
129
  "word",
@@ -175,35 +207,14 @@
175
207
  "y-protocols": "^1.0.7",
176
208
  "yjs": "^13.6.30"
177
209
  },
178
- "scripts": {
179
- "build": "tsup",
180
- "test": "bash scripts/run-workspace-tests.sh",
181
- "test:repo": "node scripts/ci-check-layout-engine-version.mjs && node scripts/run-repo-tests.mjs core",
182
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
183
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
184
- "test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
185
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
186
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
187
- "test:visual": "VISUAL_SMOKE_PROFILE=bare pnpm exec playwright test --project=chromium",
188
- "test:visual:chrome": "VISUAL_SMOKE_PROFILE=chrome-cycle pnpm exec playwright test --project=chromium",
189
- "visual:list-runs": "node scripts/visual-smoke-list-runs.mjs",
190
- "mcp:visual-smoke": "node scripts/visual-smoke-mcp.mjs",
191
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
192
- "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
193
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
194
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
195
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
196
- "generate:token-reference": "node scripts/generate-token-reference.mjs",
197
- "check:token-reference": "node scripts/generate-token-reference.mjs --check",
198
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
199
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
200
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
201
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
202
- "wave:launch:managed": "bash scripts/wave-launch.sh",
203
- "wave:status": "bash scripts/wave-status.sh",
204
- "wave:watch": "bash scripts/wave-watch.sh --follow",
205
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
206
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
207
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
210
+ "pnpm": {
211
+ "onlyBuiltDependencies": [
212
+ "esbuild",
213
+ "sharp"
214
+ ],
215
+ "overrides": {
216
+ "react": "19.2.4",
217
+ "react-dom": "19.2.4"
218
+ }
208
219
  }
209
- }
220
+ }
@@ -858,6 +858,13 @@ export type SurfaceInlineSegment =
858
858
  * a text badge. Absent when the object has no cached fallback image.
859
859
  */
860
860
  previewMediaId?: string;
861
+ /**
862
+ * Stable chart ID (Stage 6). Present when the `chart_preview` node has a
863
+ * successfully parsed `ChartModel` (`parsedData` populated at import time).
864
+ * The ID keys into the module-level `chartModelStore` so `ChartNodeView`
865
+ * can render `ChartSurface` instead of the fallback bitmap or badge.
866
+ */
867
+ parsedChartId?: string;
861
868
  state: "locked-preserve-only";
862
869
  }
863
870
  | {
@@ -2846,6 +2853,80 @@ export interface EditorTelemetryEvent {
2846
2853
  detail?: Record<string, unknown>;
2847
2854
  }
2848
2855
 
2856
+ // ---------------------------------------------------------------------------
2857
+ // Chart read-model (Stage 6)
2858
+ // ---------------------------------------------------------------------------
2859
+
2860
+ /**
2861
+ * Structured, typed representation of a chart's data. Returned by
2862
+ * `WordReviewEditorRef.getChartSnapshot` so agents can reason over chart
2863
+ * content without re-parsing `rawXml`. Charts are read-only; agents must not
2864
+ * mutate this object.
2865
+ */
2866
+ export interface ChartSnapshot {
2867
+ chartId: string;
2868
+ kind: string;
2869
+ title?: string;
2870
+ seriesCount: number;
2871
+ categoryCount: number;
2872
+ data: ChartSnapshotData;
2873
+ }
2874
+
2875
+ export type ChartSnapshotData =
2876
+ | {
2877
+ kind: "bar";
2878
+ direction: "bar" | "column";
2879
+ grouping: "clustered" | "stacked" | "percentStacked" | "standard";
2880
+ series: ChartSnapshotSeries[];
2881
+ categories: string[];
2882
+ }
2883
+ | {
2884
+ kind: "line";
2885
+ grouping: "standard" | "stacked" | "percentStacked";
2886
+ series: ChartSnapshotSeries[];
2887
+ categories: string[];
2888
+ }
2889
+ | {
2890
+ kind: "pie";
2891
+ doughnut: boolean;
2892
+ series: ChartSnapshotSeries[];
2893
+ categories: string[];
2894
+ }
2895
+ | {
2896
+ kind: "area";
2897
+ grouping: "standard" | "stacked" | "percentStacked";
2898
+ series: ChartSnapshotSeries[];
2899
+ categories: string[];
2900
+ }
2901
+ | { kind: "scatter"; series: ChartSnapshotScatterSeries[] }
2902
+ | { kind: "bubble"; series: ChartSnapshotBubbleSeries[] }
2903
+ | {
2904
+ kind: "combo";
2905
+ groups: Array<{ kind: "bar" | "line" | "area"; series: ChartSnapshotSeries[] }>;
2906
+ categories: string[];
2907
+ }
2908
+ | { kind: "unsupported"; reason: string };
2909
+
2910
+ export interface ChartSnapshotSeries {
2911
+ name?: string;
2912
+ values: Array<number | null>;
2913
+ }
2914
+
2915
+ export interface ChartSnapshotScatterSeries {
2916
+ name?: string;
2917
+ xValues: Array<number | null>;
2918
+ yValues: Array<number | null>;
2919
+ }
2920
+
2921
+ export interface ChartSnapshotBubbleSeries {
2922
+ name?: string;
2923
+ xValues: Array<number | null>;
2924
+ yValues: Array<number | null>;
2925
+ sizes: Array<number | null>;
2926
+ }
2927
+
2928
+ // ---------------------------------------------------------------------------
2929
+
2849
2930
  /**
2850
2931
  * Stage 0B.1 — parameters passed to `EditorHostAdapter.renderChartPreview`
2851
2932
  * when the importer encounters a `c:chartSpace` that has no cached
@@ -2949,6 +3030,15 @@ export interface WordReviewEditorRef {
2949
3030
  getRenderSnapshot(): RuntimeRenderSnapshot;
2950
3031
  getCompatibilityReport(): CompatibilityReport;
2951
3032
  getWarnings(): EditorWarning[];
3033
+ /**
3034
+ * Stage 6 — structured chart data for a specific chart by ID. Returns `null`
3035
+ * when the chart ID is not found or the chart has no parsed model (e.g. parse
3036
+ * failed at import time, or chart type is unsupported). Use `getChartSnapshots`
3037
+ * to enumerate all charts in the document.
3038
+ */
3039
+ getChartSnapshot(chartId: string): ChartSnapshot | null;
3040
+ /** Stage 6 — all charts in the document that have a parsed model. */
3041
+ getChartSnapshots(): ChartSnapshot[];
2952
3042
  getCommentSidebarSnapshot(): CommentSidebarSnapshot;
2953
3043
  getTrackedChangesSnapshot(): TrackedChangesSnapshot;
2954
3044
  getSuggestionsSnapshot(): SuggestionsSnapshot;
package/src/index.ts CHANGED
@@ -122,6 +122,11 @@ export type {
122
122
  EditorSessionState,
123
123
  EditorHostAdapter,
124
124
  ChartPreviewResolveParams,
125
+ ChartSnapshot,
126
+ ChartSnapshotData,
127
+ ChartSnapshotSeries,
128
+ ChartSnapshotScatterSeries,
129
+ ChartSnapshotBubbleSeries,
125
130
  WordReviewEditorProps,
126
131
  WordReviewEditorChromePreset,
127
132
  WordReviewEditorChromeOptions,
@@ -73,6 +73,8 @@ import {
73
73
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
74
74
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE,
75
75
  WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
76
+ WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
77
+ WORKFLOW_PAYLOAD_PART_PATH,
76
78
  WORKFLOW_PAYLOAD_RELATIONSHIP_TYPE,
77
79
  } from "./ooxml/workflow-payload.ts";
78
80
  import {
@@ -2104,20 +2106,17 @@ function exportDocxEditorSession(
2104
2106
  const hasSettingsSurface =
2105
2107
  Boolean(state.sourceSettingsPartPath) ||
2106
2108
  exportedSubParts?.settings !== undefined;
2107
- const resolvedWorkflowPayloadPartPaths = resolveWorkflowPayloadPartPaths(
2109
+ const workflowPayloadPartPaths = resolveWorkflowPayloadPartPaths(
2108
2110
  state.sourcePackage,
2109
2111
  sessionState.documentId,
2110
2112
  );
2111
- const internalEditorState = (
2112
- options as { _editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload } | undefined
2113
- )?._editorState;
2114
2113
 
2115
2114
  const exportSession = createExportSession(state.sourcePackage, [
2116
2115
  state.sourceDocumentPartPath,
2117
2116
  APP_PROPERTIES_PART_PATH,
2118
2117
  CORE_PROPERTIES_PART_PATH,
2119
- resolvedWorkflowPayloadPartPaths.payloadPartPath,
2120
- resolvedWorkflowPayloadPartPaths.itemPropsPartPath,
2118
+ workflowPayloadPartPaths.payloadPartPath,
2119
+ workflowPayloadPartPaths.itemPropsPartPath,
2121
2120
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
2122
2121
  numberingPartPath,
2123
2122
  commentsPartPath,
@@ -2366,12 +2365,13 @@ function exportDocxEditorSession(
2366
2365
 
2367
2366
  ensureHostMetadataParts(exportSession, state.sourcePackage, currentDocument);
2368
2367
  // Schema 1.2: pass through editorState payload collected by the runtime channel.
2368
+ const internalEditorState = (options as { _editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload } | undefined)?._editorState;
2369
2369
  ensureWorkflowPayloadParts(
2370
2370
  exportSession,
2371
2371
  sessionState,
2372
2372
  currentDocument,
2373
2373
  state.sourcePackage,
2374
- resolvedWorkflowPayloadPartPaths,
2374
+ workflowPayloadPartPaths,
2375
2375
  internalEditorState,
2376
2376
  );
2377
2377
 
@@ -560,6 +560,7 @@ function normalizeInlineChildren(
560
560
  children: fieldChildren,
561
561
  fieldFamily: classification.family,
562
562
  ...(classification.target ? { fieldTarget: classification.target } : {}),
563
+ ...(classification.switches ? { switches: classification.switches } : {}),
563
564
  refreshStatus: classification.supported ? "stale" : "preserve-only",
564
565
  });
565
566
  state.cursor += renderedLength > 0 ? renderedLength : 1;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * parse-field-switches.ts
3
+ *
4
+ * Pure tokenizer for OOXML field instruction switches.
5
+ * Used by parse-fields.ts to populate FieldNode.switches for REF/PAGEREF/NOTEREF/TOC.
6
+ *
7
+ * No runtime dependencies — this lives in the io layer alongside parse-fields.ts.
8
+ */
9
+
10
+ export interface ParsedFieldSwitches {
11
+ /** \h — render as hyperlink */
12
+ hyperlink: boolean;
13
+ /** \p — emit relative position (above/below/on this page) */
14
+ relativePosition: boolean;
15
+ /** \n — use numeric reference */
16
+ numericRef: boolean;
17
+ /** \r — recursive */
18
+ recursive: boolean;
19
+ /** \t — suppress non-delimiter characters */
20
+ suppressNonDelimiter: boolean;
21
+ /** \w — include numbering prefix */
22
+ includeNumbering: boolean;
23
+ /** \l — include level */
24
+ includeLevel: boolean;
25
+ /** Any unrecognized switch letters (e.g. \*, \@, \#) */
26
+ unknownSwitches: string[];
27
+ }
28
+
29
+ /**
30
+ * Tokenize a field instruction string.
31
+ * Quoted strings (starting with ") are treated as a single token.
32
+ * Other tokens are split on whitespace.
33
+ */
34
+ function tokenizeInstruction(s: string): string[] {
35
+ const tokens: string[] = [];
36
+ let i = 0;
37
+ while (i < s.length) {
38
+ // Skip whitespace
39
+ while (i < s.length && /\s/.test(s[i]!)) i += 1;
40
+ if (i >= s.length) break;
41
+
42
+ if (s[i] === '"') {
43
+ // Quoted token — scan to closing quote
44
+ const start = i;
45
+ i += 1; // skip opening quote
46
+ while (i < s.length && s[i] !== '"') i += 1;
47
+ if (i < s.length) i += 1; // skip closing quote
48
+ tokens.push(s.slice(start, i));
49
+ } else {
50
+ // Normal whitespace-delimited token
51
+ const start = i;
52
+ while (i < s.length && !/\s/.test(s[i]!)) i += 1;
53
+ tokens.push(s.slice(start, i));
54
+ }
55
+ }
56
+ return tokens;
57
+ }
58
+
59
+ /**
60
+ * Parse field switches from a raw field instruction string.
61
+ * Handles case-insensitive switch letters (\h, \p, \n, \r, \t, \w, \l).
62
+ * General-purpose switches (\*, \@, \#) each consume the next token as argument.
63
+ */
64
+ export function parseFieldSwitches(instruction: string): ParsedFieldSwitches {
65
+ const result: ParsedFieldSwitches = {
66
+ hyperlink: false,
67
+ relativePosition: false,
68
+ numericRef: false,
69
+ recursive: false,
70
+ suppressNonDelimiter: false,
71
+ includeNumbering: false,
72
+ includeLevel: false,
73
+ unknownSwitches: [],
74
+ };
75
+
76
+ const tokens = tokenizeInstruction(instruction);
77
+ let i = 0;
78
+
79
+ // Skip leading non-switch tokens (field name, target bookmark, quoted names)
80
+ while (i < tokens.length && !tokens[i]!.startsWith("\\")) {
81
+ i += 1;
82
+ }
83
+
84
+ // Process switch tokens
85
+ while (i < tokens.length) {
86
+ const token = tokens[i]!;
87
+ i += 1;
88
+
89
+ if (!token.startsWith("\\")) {
90
+ // Not a switch — skip (e.g. argument to a previous switch)
91
+ continue;
92
+ }
93
+
94
+ const lower = token.toLowerCase();
95
+
96
+ switch (lower) {
97
+ case "\\h":
98
+ result.hyperlink = true;
99
+ break;
100
+ case "\\p":
101
+ result.relativePosition = true;
102
+ break;
103
+ case "\\n":
104
+ result.numericRef = true;
105
+ break;
106
+ case "\\r":
107
+ result.recursive = true;
108
+ break;
109
+ case "\\t":
110
+ result.suppressNonDelimiter = true;
111
+ break;
112
+ case "\\w":
113
+ result.includeNumbering = true;
114
+ break;
115
+ case "\\l":
116
+ result.includeLevel = true;
117
+ break;
118
+ case "\\*":
119
+ case "\\@":
120
+ case "\\#":
121
+ // General-purpose switches — consume next token as argument
122
+ result.unknownSwitches.push(token);
123
+ if (i < tokens.length) {
124
+ i += 1; // consume argument
125
+ }
126
+ break;
127
+ default:
128
+ result.unknownSwitches.push(token);
129
+ break;
130
+ }
131
+ }
132
+
133
+ return result;
134
+ }
@@ -211,9 +211,10 @@ import type {
211
211
  TocEntry,
212
212
  TocStructure,
213
213
  } from "../../model/canonical-document.ts";
214
+ import { parseFieldSwitches } from "./parse-field-switches.ts";
214
215
 
215
216
  const FIELD_FAMILY_PATTERN =
216
- /^\s*(REF|PAGEREF|NOTEREF|TOC|PAGE|NUMPAGES|DATE|TIME|AUTHOR|FILENAME|MERGEFIELD|IF|SEQ|INDEX|TC|STYLEREF)\b/i;
217
+ /^\s*(REF|PAGEREF|NOTEREF|TOC|PAGE|NUMPAGES|DATE|TIME|AUTHOR|FILENAME|MERGEFIELD|IF|SEQ|INDEX|TC|STYLEREF|SECTIONPAGES)\b/i;
217
218
 
218
219
  const SUPPORTED_FAMILIES = new Set<string>([
219
220
  "REF",
@@ -222,16 +223,20 @@ const SUPPORTED_FAMILIES = new Set<string>([
222
223
  "TOC",
223
224
  "PAGE",
224
225
  "NUMPAGES",
226
+ "STYLEREF",
227
+ "SECTIONPAGES",
225
228
  ]);
226
229
 
227
230
  /**
228
231
  * Classify a field instruction into its field family.
229
232
  * Returns the family enum value and whether it is in the supported slice.
233
+ * For REF/PAGEREF/NOTEREF/TOC families, also parses field switches.
230
234
  */
231
235
  export function classifyFieldInstruction(instruction: string): {
232
236
  family: FieldFamily;
233
237
  supported: boolean;
234
238
  target?: string;
239
+ switches?: FieldNode["switches"];
235
240
  } {
236
241
  const trimmed = instruction.trim();
237
242
  const match = FIELD_FAMILY_PATTERN.exec(trimmed);
@@ -247,8 +252,29 @@ export function classifyFieldInstruction(instruction: string): {
247
252
  const targetMatch = /^\s*(?:REF|PAGEREF|NOTEREF)\s+(?:"([^"]+)"|(\S+))/i.exec(trimmed);
248
253
  target = (targetMatch?.[1] ?? targetMatch?.[2])?.trim();
249
254
  }
255
+ if (family === "STYLEREF") {
256
+ const styleMatch = /^\s*STYLEREF\s+(?:"([^"]+)"|(\S+))/i.exec(trimmed);
257
+ const extracted = styleMatch?.[1] ?? styleMatch?.[2];
258
+ if (extracted) target = extracted.trim();
259
+ }
260
+
261
+ let switches: FieldNode["switches"] | undefined;
262
+ if (family === "REF" || family === "PAGEREF" || family === "NOTEREF" || family === "TOC") {
263
+ const raw = parseFieldSwitches(trimmed);
264
+ const sw: FieldNode["switches"] = {};
265
+ if (raw.hyperlink) sw.hyperlink = true;
266
+ if (raw.relativePosition) sw.relativePosition = true;
267
+ if (raw.numericRef) sw.numericRef = true;
268
+ if (raw.recursive) sw.recursive = true;
269
+ if (raw.suppressNonDelimiter) sw.suppressNonDelimiter = true;
270
+ if (raw.includeNumbering) sw.includeNumbering = true;
271
+ if (raw.includeLevel) sw.includeLevel = true;
272
+ if (Object.keys(sw).length > 0) {
273
+ switches = sw;
274
+ }
275
+ }
250
276
 
251
- return { family, supported, target };
277
+ return { family, supported, target, ...(switches ? { switches } : {}) };
252
278
  }
253
279
 
254
280
  /**
@@ -804,7 +804,9 @@ export type SupportedFieldFamily =
804
804
  | "NOTEREF"
805
805
  | "TOC"
806
806
  | "PAGE"
807
- | "NUMPAGES";
807
+ | "NUMPAGES"
808
+ | "STYLEREF"
809
+ | "SECTIONPAGES";
808
810
 
809
811
  /**
810
812
  * Unsupported field families that remain preserve-only.
@@ -820,7 +822,6 @@ export type PreserveOnlyFieldFamily =
820
822
  | "SEQ"
821
823
  | "INDEX"
822
824
  | "TC"
823
- | "STYLEREF"
824
825
  | "FORMULA"
825
826
  | "UNKNOWN";
826
827
 
@@ -844,6 +845,16 @@ export interface FieldNode {
844
845
  fieldTarget?: string;
845
846
  /** Runtime refresh status. Undefined for legacy or preserve-only fields. */
846
847
  refreshStatus?: FieldRefreshStatus;
848
+ /** Parsed field switches. Present only when the instruction contains recognized switches. */
849
+ switches?: {
850
+ hyperlink?: boolean;
851
+ relativePosition?: boolean;
852
+ numericRef?: boolean;
853
+ recursive?: boolean;
854
+ suppressNonDelimiter?: boolean;
855
+ includeNumbering?: boolean;
856
+ includeLevel?: boolean;
857
+ };
847
858
  }
848
859
 
849
860
  // ─── Field registry ─────────────────────────────────────────────────────────
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Module-level cache of parsed ChartModel instances keyed by a stable chart ID.
3
+ * Populated as a side effect of `surface-projection.ts` when a `chart_preview`
4
+ * node carries `parsedData`. Consumed by `ChartNodeView` to render `ChartSurface`.
5
+ *
6
+ * Charts are preserve-only (rawXml never mutated), so entries are stable for the
7
+ * lifetime of the document. `clear()` is called before repopulating on document
8
+ * replacement.
9
+ */
10
+
11
+ import type { ChartModel } from "../../io/ooxml/chart/types.ts";
12
+ import type { ResolvedTheme } from "../../model/canonical-document.ts";
13
+
14
+ export interface ChartStoreEntry {
15
+ readonly model: ChartModel;
16
+ readonly widthPx: number;
17
+ readonly heightPx: number;
18
+ readonly theme: ResolvedTheme | undefined;
19
+ }
20
+
21
+ const _store = new Map<string, ChartStoreEntry>();
22
+
23
+ export const chartModelStore = {
24
+ set(chartId: string, entry: ChartStoreEntry): void {
25
+ _store.set(chartId, entry);
26
+ },
27
+ get(chartId: string): ChartStoreEntry | undefined {
28
+ return _store.get(chartId);
29
+ },
30
+ clear(): void {
31
+ _store.clear();
32
+ },
33
+ size(): number {
34
+ return _store.size;
35
+ },
36
+ has(chartId: string): boolean {
37
+ return _store.has(chartId);
38
+ },
39
+ values(): IterableIterator<ChartStoreEntry> {
40
+ return _store.values();
41
+ },
42
+ };
43
+
44
+ const EMU_PER_PX = 9525;
45
+
46
+ /**
47
+ * Extract drawing extent from rawXml and convert EMU → pixels.
48
+ * Tries `wp:extent` first (wp:inline/wp:anchor drawings), then `a:ext` as
49
+ * fallback (xfrm transforms). Falls back to a sensible 6×3.5 inch default.
50
+ */
51
+ export function extractChartDimensions(rawXml: string): { widthPx: number; heightPx: number } {
52
+ const extentMatch = /<wp:extent\b([^/>]*)\/?>/.exec(rawXml);
53
+ if (extentMatch) {
54
+ const attrs = extentMatch[1] ?? "";
55
+ const cx = /\bcx="(\d+)"/.exec(attrs)?.[1];
56
+ const cy = /\bcy="(\d+)"/.exec(attrs)?.[1];
57
+ if (cx && cy) {
58
+ return {
59
+ widthPx: Math.round(parseInt(cx, 10) / EMU_PER_PX),
60
+ heightPx: Math.round(parseInt(cy, 10) / EMU_PER_PX),
61
+ };
62
+ }
63
+ }
64
+ const aExtMatch = /\ba:ext\s[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/.exec(rawXml);
65
+ if (aExtMatch) {
66
+ return {
67
+ widthPx: Math.round(parseInt(aExtMatch[1]!, 10) / EMU_PER_PX),
68
+ heightPx: Math.round(parseInt(aExtMatch[2]!, 10) / EMU_PER_PX),
69
+ };
70
+ }
71
+ return { widthPx: 576, heightPx: 336 };
72
+ }
73
+
74
+ /**
75
+ * Stable chart ID derived from rawXml content. Charts are never mutated so the
76
+ * ID is stable for the lifetime of the document load.
77
+ *
78
+ * Uses djb2 hash + length suffix to minimize collisions without importing a
79
+ * crypto dependency.
80
+ */
81
+ export function stableChartId(rawXml: string): string {
82
+ let h = 5381;
83
+ for (let i = 0; i < rawXml.length; i++) {
84
+ h = ((h << 5) + h) ^ rawXml.charCodeAt(i);
85
+ h = h | 0;
86
+ }
87
+ return `chart-${(h >>> 0).toString(16)}-${rawXml.length}`;
88
+ }