@beyondwork/docx-react-component 1.0.53 → 1.0.55

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 (99) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +125 -7
  3. package/src/index.ts +5 -0
  4. package/src/io/docx-session.ts +27 -3
  5. package/src/io/normalize/normalize-text.ts +1 -0
  6. package/src/io/ooxml/parse-field-switches.ts +134 -0
  7. package/src/io/ooxml/parse-fields.ts +28 -2
  8. package/src/model/canonical-document.ts +13 -2
  9. package/src/runtime/chart/chart-model-store.ts +88 -0
  10. package/src/runtime/chart/chart-snapshot.ts +239 -0
  11. package/src/runtime/collab/checkpoint-store.ts +1 -1
  12. package/src/runtime/collab/event-types.ts +4 -0
  13. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  14. package/src/runtime/document-runtime.ts +115 -13
  15. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  16. package/src/runtime/layout/layout-engine-version.ts +58 -1
  17. package/src/runtime/layout/layout-invalidation.ts +150 -30
  18. package/src/runtime/layout/page-graph.ts +19 -0
  19. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  20. package/src/runtime/layout/project-block-fragments.ts +27 -0
  21. package/src/runtime/layout/public-facet.ts +27 -0
  22. package/src/runtime/page-number-format.ts +207 -0
  23. package/src/runtime/render/render-frame-diff.ts +38 -2
  24. package/src/runtime/surface-projection.ts +32 -3
  25. package/src/ui/WordReviewEditor.tsx +57 -3
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +90 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
  78. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
  79. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  80. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
  81. package/src/ui-tailwind/index.ts +11 -0
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  94. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  95. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  96. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  97. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  98. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  99. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.53",
4
+ "version": "1.0.55",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -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;
@@ -3058,12 +3148,16 @@ export interface WordReviewEditorRef {
3058
3148
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
3059
3149
  clearWorkflowOverlay(): void;
3060
3150
  /**
3061
- * Read the current runtime-backed `WorkflowOverlay`, or `null` when no
3062
- * overlay is active. Hosts use this for read-modify-write flows against
3063
- * `setWorkflowOverlay(...)` notably to preserve engine-authored scopes
3064
- * minted via `addScope(...)` when updating candidates / work items.
3065
- * Returns a defensive clone; mutating the returned object does not affect
3066
- * runtime state.
3151
+ * Return a structural clone of the currently-registered workflow
3152
+ * overlay, or `null` when no overlay has been set (or
3153
+ * `clearWorkflowOverlay` has been called). Intended for the canonical
3154
+ * host read-before-write pattern read current, merge host-owned
3155
+ * fields (e.g. `candidates`), write back via `setWorkflowOverlay`
3156
+ * without clobbering engine-authored `scopes`, `workItems`, or the
3157
+ * overlay-level `metadataPersistence` default. `setWorkflowOverlay`
3158
+ * replaces the overlay wholesale; passing `scopes: []` will drop
3159
+ * every scope registered via `addScope` and cause subsequent
3160
+ * `replaceText` calls to block with `workflow_comment_only`.
3067
3161
  */
3068
3162
  getWorkflowOverlay(): WorkflowOverlay | null;
3069
3163
  setSharedWorkflowState(state: SharedWorkflowState | null): void;
@@ -3304,7 +3398,31 @@ export interface WordReviewEditorProps {
3304
3398
  suggestionsEnabled?: boolean;
3305
3399
  chromePreset?: WordReviewEditorChromePreset;
3306
3400
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
3307
- markupDisplay?: "clean" | "simple" | "all";
3401
+ /**
3402
+ * Controls how tracked changes and comments render. Accepts Word's
3403
+ * 4-mode grammar (`"all-markup" | "simple-markup" | "no-markup" | "original"`)
3404
+ * or the legacy triple (`"all" | "simple" | "clean"`), which maps 1:1
3405
+ * onto the first three canonical names. `"original"` is new in 6d.N2:
3406
+ * insertions are hidden, deletions render as plain body text so the
3407
+ * reviewer sees the pre-change state.
3408
+ */
3409
+ markupDisplay?:
3410
+ | "all-markup"
3411
+ | "simple-markup"
3412
+ | "no-markup"
3413
+ | "original"
3414
+ | "clean"
3415
+ | "simple"
3416
+ | "all";
3417
+ /**
3418
+ * L6d.N2 — invoked when the user picks a different display mode from
3419
+ * the in-chrome selector. Values are always emitted in the canonical
3420
+ * Word grammar (`"all-markup" | "simple-markup" | "no-markup" | "original"`),
3421
+ * not the legacy aliases.
3422
+ */
3423
+ onMarkupDisplayChange?: (
3424
+ value: "all-markup" | "simple-markup" | "no-markup" | "original",
3425
+ ) => void;
3308
3426
  /**
3309
3427
  * @internal HARNESS-ONLY debug-ports token.
3310
3428
  *
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,
@@ -67,6 +67,7 @@ import {
67
67
  getDocumentBackedWorkflowMetadata,
68
68
  parseWorkflowPayloadEnvelopeFromPackage,
69
69
  resolvePayloadPartPath,
70
+ resolveWorkflowPayloadPartPaths,
70
71
  WORKFLOW_PAYLOAD_CONTENT_TYPE,
71
72
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE,
72
73
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
@@ -2105,13 +2106,17 @@ function exportDocxEditorSession(
2105
2106
  const hasSettingsSurface =
2106
2107
  Boolean(state.sourceSettingsPartPath) ||
2107
2108
  exportedSubParts?.settings !== undefined;
2109
+ const workflowPayloadPartPaths = resolveWorkflowPayloadPartPaths(
2110
+ state.sourcePackage,
2111
+ sessionState.documentId,
2112
+ );
2108
2113
 
2109
2114
  const exportSession = createExportSession(state.sourcePackage, [
2110
2115
  state.sourceDocumentPartPath,
2111
2116
  APP_PROPERTIES_PART_PATH,
2112
2117
  CORE_PROPERTIES_PART_PATH,
2113
- WORKFLOW_PAYLOAD_PART_PATH,
2114
- WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
2118
+ workflowPayloadPartPaths.payloadPartPath,
2119
+ workflowPayloadPartPaths.itemPropsPartPath,
2115
2120
  WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
2116
2121
  numberingPartPath,
2117
2122
  commentsPartPath,
@@ -2361,7 +2366,14 @@ function exportDocxEditorSession(
2361
2366
  ensureHostMetadataParts(exportSession, state.sourcePackage, currentDocument);
2362
2367
  // Schema 1.2: pass through editorState payload collected by the runtime channel.
2363
2368
  const internalEditorState = (options as { _editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload } | undefined)?._editorState;
2364
- ensureWorkflowPayloadParts(exportSession, sessionState, currentDocument, state.sourcePackage, internalEditorState);
2369
+ ensureWorkflowPayloadParts(
2370
+ exportSession,
2371
+ sessionState,
2372
+ currentDocument,
2373
+ state.sourcePackage,
2374
+ workflowPayloadPartPaths,
2375
+ internalEditorState,
2376
+ );
2365
2377
 
2366
2378
  return {
2367
2379
  bytes: exportSession.serialize(),
@@ -4087,6 +4099,10 @@ function ensureWorkflowPayloadParts(
4087
4099
  sessionState: EditorSessionState,
4088
4100
  document: CanonicalDocumentEnvelope,
4089
4101
  sourcePackage: OpcPackage,
4102
+ resolvedPartPaths: {
4103
+ payloadPartPath: string;
4104
+ itemPropsPartPath: string;
4105
+ },
4090
4106
  editorState?: import("./ooxml/workflow-payload.ts").EditorStatePayload,
4091
4107
  ): void {
4092
4108
  const payloadParts = buildWorkflowPayloadParts({
@@ -4102,6 +4118,14 @@ function ensureWorkflowPayloadParts(
4102
4118
  if (!payloadParts) {
4103
4119
  return;
4104
4120
  }
4121
+ if (
4122
+ payloadParts.payloadPartPath !== resolvedPartPaths.payloadPartPath ||
4123
+ payloadParts.itemPropsPartPath !== resolvedPartPaths.itemPropsPartPath
4124
+ ) {
4125
+ throw new Error(
4126
+ "Workflow payload export resolved inconsistent customXml paths; export session ownership no longer matches payload serialization.",
4127
+ );
4128
+ }
4105
4129
 
4106
4130
  const payloadPart = sourcePackage.parts.get(payloadParts.payloadPartPath);
4107
4131
  const itemPropsPart = sourcePackage.parts.get(payloadParts.itemPropsPartPath);
@@ -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
+ }