@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 +43 -32
- package/src/api/public-types.ts +90 -0
- package/src/index.ts +5 -0
- package/src/io/docx-session.ts +7 -7
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/parse-field-switches.ts +134 -0
- package/src/io/ooxml/parse-fields.ts +28 -2
- package/src/model/canonical-document.ts +13 -2
- package/src/runtime/chart/chart-model-store.ts +88 -0
- package/src/runtime/chart/chart-snapshot.ts +239 -0
- package/src/runtime/document-runtime.ts +96 -8
- package/src/runtime/page-number-format.ts +207 -0
- package/src/runtime/surface-projection.ts +32 -3
- package/src/ui/WordReviewEditor.tsx +51 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +90 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +8 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -1
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
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.
|
|
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
|
-
"
|
|
179
|
-
"
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
}
|
package/src/api/public-types.ts
CHANGED
|
@@ -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,
|
package/src/io/docx-session.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
2120
|
-
|
|
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
|
-
|
|
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
|
+
}
|