@beyondwork/docx-react-component 1.0.54 → 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.
- package/package.json +1 -1
- 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 +4 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public read-model for chart data. Projected from a `ChartModel` parsed during
|
|
3
|
+
* DOCX import. Agents consume this without re-parsing `rawXml`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ChartModel,
|
|
8
|
+
BarChartModel,
|
|
9
|
+
LineChartModel,
|
|
10
|
+
PieChartModel,
|
|
11
|
+
AreaChartModel,
|
|
12
|
+
ScatterChartModel,
|
|
13
|
+
BubbleChartModel,
|
|
14
|
+
ComboChartModel,
|
|
15
|
+
} from "../../io/ooxml/chart/types.ts";
|
|
16
|
+
|
|
17
|
+
export interface ChartSnapshot {
|
|
18
|
+
chartId: string;
|
|
19
|
+
kind: ChartModel["kind"];
|
|
20
|
+
title?: string;
|
|
21
|
+
seriesCount: number;
|
|
22
|
+
categoryCount: number;
|
|
23
|
+
data: ChartSnapshotData;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ChartSnapshotData =
|
|
27
|
+
| {
|
|
28
|
+
kind: "bar";
|
|
29
|
+
direction: "bar" | "column";
|
|
30
|
+
grouping: "clustered" | "stacked" | "percentStacked" | "standard";
|
|
31
|
+
series: ChartSnapshotSeries[];
|
|
32
|
+
categories: string[];
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
kind: "line";
|
|
36
|
+
grouping: "standard" | "stacked" | "percentStacked";
|
|
37
|
+
series: ChartSnapshotSeries[];
|
|
38
|
+
categories: string[];
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
kind: "pie";
|
|
42
|
+
doughnut: boolean;
|
|
43
|
+
series: ChartSnapshotSeries[];
|
|
44
|
+
categories: string[];
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
kind: "area";
|
|
48
|
+
grouping: "standard" | "stacked" | "percentStacked";
|
|
49
|
+
series: ChartSnapshotSeries[];
|
|
50
|
+
categories: string[];
|
|
51
|
+
}
|
|
52
|
+
| { kind: "scatter"; series: ChartSnapshotScatterSeries[] }
|
|
53
|
+
| { kind: "bubble"; series: ChartSnapshotBubbleSeries[] }
|
|
54
|
+
| {
|
|
55
|
+
kind: "combo";
|
|
56
|
+
groups: Array<{ kind: "bar" | "line" | "area"; series: ChartSnapshotSeries[] }>;
|
|
57
|
+
categories: string[];
|
|
58
|
+
}
|
|
59
|
+
| { kind: "unsupported"; reason: string };
|
|
60
|
+
|
|
61
|
+
export interface ChartSnapshotSeries {
|
|
62
|
+
name?: string;
|
|
63
|
+
values: Array<number | null>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ChartSnapshotScatterSeries {
|
|
67
|
+
name?: string;
|
|
68
|
+
xValues: Array<number | null>;
|
|
69
|
+
yValues: Array<number | null>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ChartSnapshotBubbleSeries {
|
|
73
|
+
name?: string;
|
|
74
|
+
xValues: Array<number | null>;
|
|
75
|
+
yValues: Array<number | null>;
|
|
76
|
+
sizes: Array<number | null>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function projectChartSnapshot(chartId: string, model: ChartModel): ChartSnapshot {
|
|
80
|
+
switch (model.kind) {
|
|
81
|
+
case "bar": {
|
|
82
|
+
const m = model as BarChartModel;
|
|
83
|
+
const categories = m.categoryAxis.kind === "category"
|
|
84
|
+
? m.categoryAxis.categoryLabels
|
|
85
|
+
: (m.series[0]?.categories ?? []);
|
|
86
|
+
return {
|
|
87
|
+
chartId,
|
|
88
|
+
kind: "bar",
|
|
89
|
+
title: m.title?.text,
|
|
90
|
+
seriesCount: m.series.length,
|
|
91
|
+
categoryCount: categories.length,
|
|
92
|
+
data: {
|
|
93
|
+
kind: "bar",
|
|
94
|
+
direction: m.direction,
|
|
95
|
+
grouping: m.grouping,
|
|
96
|
+
series: m.series.map((s) => ({ name: s.name, values: s.values })),
|
|
97
|
+
categories,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case "line": {
|
|
102
|
+
const m = model as LineChartModel;
|
|
103
|
+
const categories = m.categoryAxis.kind === "category"
|
|
104
|
+
? m.categoryAxis.categoryLabels
|
|
105
|
+
: (m.series[0]?.categories ?? []);
|
|
106
|
+
return {
|
|
107
|
+
chartId,
|
|
108
|
+
kind: "line",
|
|
109
|
+
title: m.title?.text,
|
|
110
|
+
seriesCount: m.series.length,
|
|
111
|
+
categoryCount: categories.length,
|
|
112
|
+
data: {
|
|
113
|
+
kind: "line",
|
|
114
|
+
grouping: m.grouping,
|
|
115
|
+
series: m.series.map((s) => ({ name: s.name, values: s.values })),
|
|
116
|
+
categories,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
case "pie": {
|
|
121
|
+
const m = model as PieChartModel;
|
|
122
|
+
return {
|
|
123
|
+
chartId,
|
|
124
|
+
kind: "pie",
|
|
125
|
+
title: m.title?.text,
|
|
126
|
+
seriesCount: m.series.length,
|
|
127
|
+
categoryCount: m.categoryLabels.length,
|
|
128
|
+
data: {
|
|
129
|
+
kind: "pie",
|
|
130
|
+
doughnut: m.doughnut,
|
|
131
|
+
series: m.series.map((s) => ({ name: s.name, values: s.values })),
|
|
132
|
+
categories: m.categoryLabels,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
case "area": {
|
|
137
|
+
const m = model as AreaChartModel;
|
|
138
|
+
const categories = m.categoryAxis.kind === "category"
|
|
139
|
+
? m.categoryAxis.categoryLabels
|
|
140
|
+
: (m.series[0]?.categories ?? []);
|
|
141
|
+
return {
|
|
142
|
+
chartId,
|
|
143
|
+
kind: "area",
|
|
144
|
+
title: m.title?.text,
|
|
145
|
+
seriesCount: m.series.length,
|
|
146
|
+
categoryCount: categories.length,
|
|
147
|
+
data: {
|
|
148
|
+
kind: "area",
|
|
149
|
+
grouping: m.grouping,
|
|
150
|
+
series: m.series.map((s) => ({ name: s.name, values: s.values })),
|
|
151
|
+
categories,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
case "scatter": {
|
|
156
|
+
const m = model as ScatterChartModel;
|
|
157
|
+
return {
|
|
158
|
+
chartId,
|
|
159
|
+
kind: "scatter",
|
|
160
|
+
seriesCount: m.series.length,
|
|
161
|
+
categoryCount: 0,
|
|
162
|
+
data: {
|
|
163
|
+
kind: "scatter",
|
|
164
|
+
series: m.series.map((s) => ({
|
|
165
|
+
name: s.name,
|
|
166
|
+
xValues: s.xValues,
|
|
167
|
+
yValues: s.yValues,
|
|
168
|
+
})),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
case "bubble": {
|
|
173
|
+
const m = model as BubbleChartModel;
|
|
174
|
+
return {
|
|
175
|
+
chartId,
|
|
176
|
+
kind: "bubble",
|
|
177
|
+
seriesCount: m.series.length,
|
|
178
|
+
categoryCount: 0,
|
|
179
|
+
data: {
|
|
180
|
+
kind: "bubble",
|
|
181
|
+
series: m.series.map((s) => ({
|
|
182
|
+
name: s.name,
|
|
183
|
+
xValues: s.xValues,
|
|
184
|
+
yValues: s.yValues,
|
|
185
|
+
sizes: s.sizes,
|
|
186
|
+
})),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
case "combo": {
|
|
191
|
+
const m = model as ComboChartModel;
|
|
192
|
+
const firstGroup = m.groups[0];
|
|
193
|
+
const categories =
|
|
194
|
+
firstGroup && "categoryAxis" in firstGroup &&
|
|
195
|
+
firstGroup.categoryAxis?.kind === "category"
|
|
196
|
+
? firstGroup.categoryAxis.categoryLabels
|
|
197
|
+
: [];
|
|
198
|
+
return {
|
|
199
|
+
chartId,
|
|
200
|
+
kind: "combo",
|
|
201
|
+
title: m.title?.text,
|
|
202
|
+
seriesCount: m.groups.reduce((acc, g) => acc + g.series.length, 0),
|
|
203
|
+
categoryCount: categories.length,
|
|
204
|
+
data: {
|
|
205
|
+
kind: "combo",
|
|
206
|
+
groups: m.groups.map((g) => ({
|
|
207
|
+
kind: g.kind as "bar" | "line" | "area",
|
|
208
|
+
series: "series" in g
|
|
209
|
+
? g.series.map((s) => ({
|
|
210
|
+
name: s.name,
|
|
211
|
+
values: "values" in s ? s.values : [],
|
|
212
|
+
}))
|
|
213
|
+
: [],
|
|
214
|
+
})),
|
|
215
|
+
categories,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
case "unsupported": {
|
|
220
|
+
return {
|
|
221
|
+
chartId,
|
|
222
|
+
kind: "unsupported",
|
|
223
|
+
seriesCount: 0,
|
|
224
|
+
categoryCount: 0,
|
|
225
|
+
data: { kind: "unsupported", reason: model.detail },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
default: {
|
|
229
|
+
const _exhaustive: never = model;
|
|
230
|
+
return {
|
|
231
|
+
chartId,
|
|
232
|
+
kind: (_exhaustive as ChartModel).kind,
|
|
233
|
+
seriesCount: 0,
|
|
234
|
+
categoryCount: 0,
|
|
235
|
+
data: { kind: "unsupported", reason: "unknown chart kind" },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -160,7 +160,6 @@ import {
|
|
|
160
160
|
createDocumentSectionSnapshots,
|
|
161
161
|
createSectionLocations,
|
|
162
162
|
createTocSnapshot,
|
|
163
|
-
findBookmarkNameForOffset,
|
|
164
163
|
findDocumentSectionSnapshot,
|
|
165
164
|
} from "./document-outline.ts";
|
|
166
165
|
import {
|
|
@@ -210,7 +209,6 @@ import type {
|
|
|
210
209
|
BlockNode,
|
|
211
210
|
FieldNode,
|
|
212
211
|
FieldRefreshStatus,
|
|
213
|
-
HyperlinkNode,
|
|
214
212
|
InlineNode,
|
|
215
213
|
PageMargins,
|
|
216
214
|
ParagraphNode,
|
|
@@ -244,6 +242,7 @@ import type {
|
|
|
244
242
|
import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
|
|
245
243
|
import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
|
|
246
244
|
import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
|
|
245
|
+
import { formatPageNumber } from "./page-number-format.ts";
|
|
247
246
|
|
|
248
247
|
/** Internal extension of ExportDocxOptions that threads the collected
|
|
249
248
|
* editorState payload from the runtime to the docx serializer. */
|
|
@@ -5198,6 +5197,32 @@ function refreshDocumentTableOfContents(
|
|
|
5198
5197
|
protectionSelection?: import("../core/state/editor-state.ts").SelectionSnapshot;
|
|
5199
5198
|
} {
|
|
5200
5199
|
const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
|
|
5200
|
+
// Build a single O(N) map from paragraph offset → bookmark name so the
|
|
5201
|
+
// per-heading lookup below is O(1) instead of O(N) per heading.
|
|
5202
|
+
const bookmarkNameByOffset = new Map<number, string>();
|
|
5203
|
+
{
|
|
5204
|
+
let runningOffset = 0;
|
|
5205
|
+
for (const block of document.content.children) {
|
|
5206
|
+
if (block.type !== "paragraph") {
|
|
5207
|
+
continue;
|
|
5208
|
+
}
|
|
5209
|
+
const paragraphStart = runningOffset;
|
|
5210
|
+
for (const child of block.children) {
|
|
5211
|
+
if (child.type === "text") {
|
|
5212
|
+
runningOffset += child.text.length;
|
|
5213
|
+
} else if (child.type === "tab" || child.type === "hard_break") {
|
|
5214
|
+
runningOffset += 1;
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
const bookmarkStart = block.children.find(
|
|
5218
|
+
(child): child is Extract<typeof child, { type: "bookmark_start" }> =>
|
|
5219
|
+
child.type === "bookmark_start" && Boolean(child.name),
|
|
5220
|
+
);
|
|
5221
|
+
if (bookmarkStart?.name) {
|
|
5222
|
+
bookmarkNameByOffset.set(paragraphStart, bookmarkStart.name);
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
5225
|
+
}
|
|
5201
5226
|
let changed = false;
|
|
5202
5227
|
let resultEntries: Array<{ level: number; text: string; pageIndex: number; bookmarkName?: string }> = [];
|
|
5203
5228
|
let changedFrom: number | undefined;
|
|
@@ -5212,7 +5237,7 @@ function refreshDocumentTableOfContents(
|
|
|
5212
5237
|
const entries = navigation.headings
|
|
5213
5238
|
.filter((heading) => heading.level >= levelRange.from && heading.level <= levelRange.to)
|
|
5214
5239
|
.map((heading) => {
|
|
5215
|
-
const bookmarkName =
|
|
5240
|
+
const bookmarkName = bookmarkNameByOffset.get(heading.offset);
|
|
5216
5241
|
return {
|
|
5217
5242
|
level: heading.level,
|
|
5218
5243
|
text: heading.text,
|
|
@@ -5426,7 +5451,7 @@ function buildTocInlineNodes(
|
|
|
5426
5451
|
type: "hyperlink",
|
|
5427
5452
|
href: `#${entry.bookmarkName}`,
|
|
5428
5453
|
children: [{ type: "text", text: entry.text }],
|
|
5429
|
-
}
|
|
5454
|
+
});
|
|
5430
5455
|
} else {
|
|
5431
5456
|
children.push({ type: "text", text: entry.text });
|
|
5432
5457
|
}
|
|
@@ -5485,6 +5510,38 @@ function collectFieldsFromSubParts(
|
|
|
5485
5510
|
return nextIndex;
|
|
5486
5511
|
}
|
|
5487
5512
|
|
|
5513
|
+
function resolveStyleRefFieldText(
|
|
5514
|
+
styleQuery: string,
|
|
5515
|
+
paragraphs: readonly ParagraphContext[],
|
|
5516
|
+
styles: CanonicalDocumentEnvelope["styles"],
|
|
5517
|
+
): { text: string; refreshStatus: FieldRefreshStatus } {
|
|
5518
|
+
const normalized = styleQuery.trim().toLowerCase();
|
|
5519
|
+
|
|
5520
|
+
// Look up styleId: first by direct id match, then by displayName
|
|
5521
|
+
const styleId = (() => {
|
|
5522
|
+
const byId = Object.keys(styles.paragraphs).find(
|
|
5523
|
+
(id) => id.toLowerCase() === normalized,
|
|
5524
|
+
);
|
|
5525
|
+
if (byId) return byId;
|
|
5526
|
+
const byName = Object.values(styles.paragraphs).find(
|
|
5527
|
+
(s) => s.displayName?.toLowerCase() === normalized,
|
|
5528
|
+
);
|
|
5529
|
+
return byName?.styleId;
|
|
5530
|
+
})();
|
|
5531
|
+
|
|
5532
|
+
if (!styleId) return { text: "", refreshStatus: "unresolvable" };
|
|
5533
|
+
|
|
5534
|
+
// Walk paragraphs top-down for the first matching styleId
|
|
5535
|
+
for (const ctx of paragraphs) {
|
|
5536
|
+
if (ctx.paragraph.styleId === styleId) {
|
|
5537
|
+
const text = flattenInlineDisplayText(ctx.paragraph.children);
|
|
5538
|
+
if (text.trim()) return { text: text.trim(), refreshStatus: "current" };
|
|
5539
|
+
}
|
|
5540
|
+
}
|
|
5541
|
+
|
|
5542
|
+
return { text: "", refreshStatus: "unresolvable" };
|
|
5543
|
+
}
|
|
5544
|
+
|
|
5488
5545
|
function resolveSupportedFieldDisplay(
|
|
5489
5546
|
field: FieldNode,
|
|
5490
5547
|
document: CanonicalDocumentEnvelope,
|
|
@@ -5499,13 +5556,30 @@ function resolveSupportedFieldDisplay(
|
|
|
5499
5556
|
if (field.fieldFamily === "TOC") {
|
|
5500
5557
|
return undefined;
|
|
5501
5558
|
}
|
|
5559
|
+
if (field.fieldFamily === "STYLEREF") {
|
|
5560
|
+
if (!field.fieldTarget) return { displayText: "", refreshStatus: "unresolvable" };
|
|
5561
|
+
const result = resolveStyleRefFieldText(field.fieldTarget, paragraphs, document.styles);
|
|
5562
|
+
return { displayText: result.text, refreshStatus: result.refreshStatus };
|
|
5563
|
+
}
|
|
5564
|
+
if (field.fieldFamily === "SECTIONPAGES") {
|
|
5565
|
+
const sectionIndex = "sectionIndex" in storyTarget && typeof storyTarget.sectionIndex === "number"
|
|
5566
|
+
? storyTarget.sectionIndex
|
|
5567
|
+
: navigation.activeSectionIndex;
|
|
5568
|
+
const sectionPages = navigation.pages.filter((p) => p.sectionIndex === sectionIndex);
|
|
5569
|
+
if (sectionPages.length === 0) return { displayText: "", refreshStatus: "unresolvable" };
|
|
5570
|
+
const fmt = sectionPages[0]?.layout.pageNumbering?.format;
|
|
5571
|
+
return {
|
|
5572
|
+
displayText: formatPageNumber(sectionPages.length, fmt),
|
|
5573
|
+
refreshStatus: "current",
|
|
5574
|
+
};
|
|
5575
|
+
}
|
|
5502
5576
|
if (field.fieldFamily === "PAGE") {
|
|
5503
5577
|
const page = resolveRepresentativePageForStory(navigation, storyTarget);
|
|
5504
5578
|
if (!page) {
|
|
5505
5579
|
return { displayText: "", refreshStatus: "unresolvable" };
|
|
5506
5580
|
}
|
|
5507
5581
|
return {
|
|
5508
|
-
displayText:
|
|
5582
|
+
displayText: resolveDisplayedPageNumber(page),
|
|
5509
5583
|
refreshStatus: "current",
|
|
5510
5584
|
};
|
|
5511
5585
|
}
|
|
@@ -5536,10 +5610,23 @@ function resolveSupportedFieldDisplay(
|
|
|
5536
5610
|
if (!paragraph) {
|
|
5537
5611
|
return { displayText: "", refreshStatus: "unresolvable" };
|
|
5538
5612
|
}
|
|
5613
|
+
|
|
5614
|
+
// \p switch: emit relative position text ("above" / "below" / "on this page")
|
|
5615
|
+
if (field.switches?.relativePosition === true) {
|
|
5616
|
+
const fieldPage = resolveRepresentativePageForStory(navigation, storyTarget);
|
|
5617
|
+
const targetPageIndex = findPageForOffset(navigation.pages, paragraph.startOffset);
|
|
5618
|
+
const fieldPageIndex = fieldPage
|
|
5619
|
+
? navigation.pages.indexOf(fieldPage)
|
|
5620
|
+
: navigation.activePageIndex;
|
|
5621
|
+
if (targetPageIndex < fieldPageIndex) return { displayText: "above", refreshStatus: "current" };
|
|
5622
|
+
if (targetPageIndex > fieldPageIndex) return { displayText: "below", refreshStatus: "current" };
|
|
5623
|
+
return { displayText: "on this page", refreshStatus: "current" };
|
|
5624
|
+
}
|
|
5625
|
+
|
|
5539
5626
|
const pageIndex = findPageForOffset(navigation.pages, paragraph.startOffset);
|
|
5540
5627
|
const page = navigation.pages[pageIndex] ?? navigation.pages[0];
|
|
5541
5628
|
return page
|
|
5542
|
-
? { displayText:
|
|
5629
|
+
? { displayText: resolveDisplayedPageNumber(page), refreshStatus: "current" }
|
|
5543
5630
|
: { displayText: "", refreshStatus: "unresolvable" };
|
|
5544
5631
|
}
|
|
5545
5632
|
if (field.fieldFamily === "NOTEREF") {
|
|
@@ -5598,8 +5685,9 @@ function isDefaultHeaderFooterPage(
|
|
|
5598
5685
|
|
|
5599
5686
|
function resolveDisplayedPageNumber(
|
|
5600
5687
|
page: DocumentNavigationSnapshot["pages"][number],
|
|
5601
|
-
):
|
|
5602
|
-
|
|
5688
|
+
): string {
|
|
5689
|
+
const n = (page.layout.pageNumbering?.start ?? 1) + page.pageInSection;
|
|
5690
|
+
return formatPageNumber(n, page.layout.pageNumbering?.format);
|
|
5603
5691
|
}
|
|
5604
5692
|
|
|
5605
5693
|
interface ParagraphContext {
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML page number format codes (ECMA-376 §17.18.59).
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally dependency-free so it can be unit-tested
|
|
5
|
+
* without any runtime or model imports.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ROMAN_PAIRS: [number, string][] = [
|
|
9
|
+
[1000, "M"],
|
|
10
|
+
[900, "CM"],
|
|
11
|
+
[500, "D"],
|
|
12
|
+
[400, "CD"],
|
|
13
|
+
[100, "C"],
|
|
14
|
+
[90, "XC"],
|
|
15
|
+
[50, "L"],
|
|
16
|
+
[40, "XL"],
|
|
17
|
+
[10, "X"],
|
|
18
|
+
[9, "IX"],
|
|
19
|
+
[5, "V"],
|
|
20
|
+
[4, "IV"],
|
|
21
|
+
[1, "I"],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function toRoman(n: number): string {
|
|
25
|
+
if (n <= 0 || n >= 4000) {
|
|
26
|
+
return String(n);
|
|
27
|
+
}
|
|
28
|
+
let result = "";
|
|
29
|
+
let remainder = n;
|
|
30
|
+
for (const [value, numeral] of ROMAN_PAIRS) {
|
|
31
|
+
while (remainder >= value) {
|
|
32
|
+
result += numeral;
|
|
33
|
+
remainder -= value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toAlphabetic(n: number): string {
|
|
40
|
+
if (n <= 0) {
|
|
41
|
+
return String(n);
|
|
42
|
+
}
|
|
43
|
+
let result = "";
|
|
44
|
+
let remainder = n;
|
|
45
|
+
while (remainder > 0) {
|
|
46
|
+
remainder -= 1;
|
|
47
|
+
result = String.fromCharCode(65 + (remainder % 26)) + result;
|
|
48
|
+
remainder = Math.floor(remainder / 26);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toOrdinal(n: number): string {
|
|
54
|
+
const abs = Math.abs(n);
|
|
55
|
+
const mod100 = abs % 100;
|
|
56
|
+
const mod10 = abs % 10;
|
|
57
|
+
if (mod100 >= 11 && mod100 <= 13) {
|
|
58
|
+
return `${n}th`;
|
|
59
|
+
}
|
|
60
|
+
if (mod10 === 1) return `${n}st`;
|
|
61
|
+
if (mod10 === 2) return `${n}nd`;
|
|
62
|
+
if (mod10 === 3) return `${n}rd`;
|
|
63
|
+
return `${n}th`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ONES = [
|
|
67
|
+
"",
|
|
68
|
+
"one",
|
|
69
|
+
"two",
|
|
70
|
+
"three",
|
|
71
|
+
"four",
|
|
72
|
+
"five",
|
|
73
|
+
"six",
|
|
74
|
+
"seven",
|
|
75
|
+
"eight",
|
|
76
|
+
"nine",
|
|
77
|
+
"ten",
|
|
78
|
+
"eleven",
|
|
79
|
+
"twelve",
|
|
80
|
+
"thirteen",
|
|
81
|
+
"fourteen",
|
|
82
|
+
"fifteen",
|
|
83
|
+
"sixteen",
|
|
84
|
+
"seventeen",
|
|
85
|
+
"eighteen",
|
|
86
|
+
"nineteen",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const TENS = [
|
|
90
|
+
"",
|
|
91
|
+
"",
|
|
92
|
+
"twenty",
|
|
93
|
+
"thirty",
|
|
94
|
+
"forty",
|
|
95
|
+
"fifty",
|
|
96
|
+
"sixty",
|
|
97
|
+
"seventy",
|
|
98
|
+
"eighty",
|
|
99
|
+
"ninety",
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
function toCardinalText(n: number): string {
|
|
103
|
+
if (n <= 0 || n >= 100) {
|
|
104
|
+
return String(n);
|
|
105
|
+
}
|
|
106
|
+
if (n < 20) {
|
|
107
|
+
return ONES[n]!;
|
|
108
|
+
}
|
|
109
|
+
const tensWord = TENS[Math.floor(n / 10)]!;
|
|
110
|
+
const onesDigit = n % 10;
|
|
111
|
+
if (onesDigit === 0) {
|
|
112
|
+
return tensWord;
|
|
113
|
+
}
|
|
114
|
+
return `${tensWord}-${ONES[onesDigit]}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ORDINAL_IRREGULARS: Record<string, string> = {
|
|
118
|
+
one: "first",
|
|
119
|
+
two: "second",
|
|
120
|
+
three: "third",
|
|
121
|
+
four: "fourth",
|
|
122
|
+
five: "fifth",
|
|
123
|
+
six: "sixth",
|
|
124
|
+
seven: "seventh",
|
|
125
|
+
eight: "eighth",
|
|
126
|
+
nine: "ninth",
|
|
127
|
+
ten: "tenth",
|
|
128
|
+
eleven: "eleventh",
|
|
129
|
+
twelve: "twelfth",
|
|
130
|
+
thirteen: "thirteenth",
|
|
131
|
+
fifteen: "fifteenth",
|
|
132
|
+
twenty: "twentieth",
|
|
133
|
+
thirty: "thirtieth",
|
|
134
|
+
forty: "fortieth",
|
|
135
|
+
fifty: "fiftieth",
|
|
136
|
+
sixty: "sixtieth",
|
|
137
|
+
seventy: "seventieth",
|
|
138
|
+
eighty: "eightieth",
|
|
139
|
+
ninety: "ninetieth",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function toOrdinalText(n: number): string {
|
|
143
|
+
if (n <= 0 || n >= 100) {
|
|
144
|
+
return `${n}th`;
|
|
145
|
+
}
|
|
146
|
+
const cardinal = toCardinalText(n);
|
|
147
|
+
// Check if it's a hyphenated compound like "twenty-one"
|
|
148
|
+
const hyphenIdx = cardinal.lastIndexOf("-");
|
|
149
|
+
if (hyphenIdx !== -1) {
|
|
150
|
+
const prefix = cardinal.slice(0, hyphenIdx + 1);
|
|
151
|
+
const suffix = cardinal.slice(hyphenIdx + 1);
|
|
152
|
+
const ordinalSuffix = ORDINAL_IRREGULARS[suffix] ?? `${suffix}th`;
|
|
153
|
+
return `${prefix}${ordinalSuffix}`;
|
|
154
|
+
}
|
|
155
|
+
return ORDINAL_IRREGULARS[cardinal] ?? `${cardinal}th`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const CHICAGO_SYMBOLS = ["*", "†", "‡", "§", "‖", "¶"];
|
|
159
|
+
|
|
160
|
+
function toChicago(n: number): string {
|
|
161
|
+
if (n <= 0) {
|
|
162
|
+
return String(n);
|
|
163
|
+
}
|
|
164
|
+
const symbolCount = CHICAGO_SYMBOLS.length;
|
|
165
|
+
// Which cycle: 1-6 → repeat 1×, 7-12 → repeat 2×, etc.
|
|
166
|
+
const cycleIndex = Math.ceil(n / symbolCount) - 1; // 0-based repeat count
|
|
167
|
+
const symbolIndex = ((n - 1) % symbolCount);
|
|
168
|
+
const symbol = CHICAGO_SYMBOLS[symbolIndex]!;
|
|
169
|
+
return symbol.repeat(cycleIndex + 1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Format a page number integer using an OOXML ST_NumberFormat code.
|
|
174
|
+
*
|
|
175
|
+
* @param n - The raw page number (1-based within section).
|
|
176
|
+
* @param format - The OOXML format code from `<w:pgNumType w:fmt="…"/>`.
|
|
177
|
+
* @returns Formatted string for display.
|
|
178
|
+
*/
|
|
179
|
+
export function formatPageNumber(n: number, format: string | undefined): string {
|
|
180
|
+
switch (format) {
|
|
181
|
+
case undefined:
|
|
182
|
+
case "decimal":
|
|
183
|
+
return String(n);
|
|
184
|
+
case "upperRoman":
|
|
185
|
+
return toRoman(n);
|
|
186
|
+
case "lowerRoman":
|
|
187
|
+
return toRoman(n).toLowerCase();
|
|
188
|
+
case "upperLetter":
|
|
189
|
+
return toAlphabetic(n);
|
|
190
|
+
case "lowerLetter":
|
|
191
|
+
return toAlphabetic(n).toLowerCase();
|
|
192
|
+
case "none":
|
|
193
|
+
return "";
|
|
194
|
+
case "ordinal":
|
|
195
|
+
return toOrdinal(n);
|
|
196
|
+
case "cardinalText":
|
|
197
|
+
return toCardinalText(n);
|
|
198
|
+
case "ordinalText":
|
|
199
|
+
return toOrdinalText(n);
|
|
200
|
+
case "hex":
|
|
201
|
+
return n.toString(16).toUpperCase();
|
|
202
|
+
case "chicago":
|
|
203
|
+
return toChicago(n);
|
|
204
|
+
default:
|
|
205
|
+
return String(n);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -8,6 +8,11 @@ import type {
|
|
|
8
8
|
SurfaceTableRowSnapshot,
|
|
9
9
|
SurfaceTextMark,
|
|
10
10
|
} from "../api/public-types";
|
|
11
|
+
import {
|
|
12
|
+
chartModelStore,
|
|
13
|
+
extractChartDimensions,
|
|
14
|
+
stableChartId,
|
|
15
|
+
} from "./chart/chart-model-store.ts";
|
|
11
16
|
import type {
|
|
12
17
|
CanonicalDocumentEnvelope,
|
|
13
18
|
SelectionSnapshot,
|
|
@@ -1011,10 +1016,25 @@ function appendInlineSegments(
|
|
|
1011
1016
|
});
|
|
1012
1017
|
return { nextCursor: start + 1, lockedFragmentIds: [node.fragmentId] };
|
|
1013
1018
|
}
|
|
1014
|
-
case "chart_preview":
|
|
1019
|
+
case "chart_preview": {
|
|
1020
|
+
let parsedChartId: string | undefined;
|
|
1021
|
+
if (node.parsedData) {
|
|
1022
|
+
parsedChartId = stableChartId(node.rawXml);
|
|
1023
|
+
if (!chartModelStore.has(parsedChartId)) {
|
|
1024
|
+
const { widthPx, heightPx } = extractChartDimensions(node.rawXml);
|
|
1025
|
+
chartModelStore.set(parsedChartId, {
|
|
1026
|
+
model: node.parsedData,
|
|
1027
|
+
widthPx,
|
|
1028
|
+
heightPx,
|
|
1029
|
+
theme: undefined,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1015
1033
|
return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
|
|
1016
1034
|
previewMediaId: node.previewMediaId,
|
|
1035
|
+
parsedChartId,
|
|
1017
1036
|
});
|
|
1037
|
+
}
|
|
1018
1038
|
case "smartart_preview":
|
|
1019
1039
|
return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node), {
|
|
1020
1040
|
previewMediaId: node.previewMediaId,
|
|
@@ -1087,6 +1107,14 @@ function appendInlineSegments(
|
|
|
1087
1107
|
node.fieldFamily === "PAGE" ||
|
|
1088
1108
|
node.fieldFamily === "NUMPAGES";
|
|
1089
1109
|
if (node.children && node.children.length > 0) {
|
|
1110
|
+
// For REF \h, pass the bookmark as a hyperlink href so child text gets hyperlink styling
|
|
1111
|
+
const refHyperlinkHref =
|
|
1112
|
+
node.fieldFamily === "REF" &&
|
|
1113
|
+
node.switches?.hyperlink === true &&
|
|
1114
|
+
node.fieldTarget
|
|
1115
|
+
? `#${node.fieldTarget}`
|
|
1116
|
+
: undefined;
|
|
1117
|
+
|
|
1090
1118
|
let cursor = start;
|
|
1091
1119
|
const lockedIds: string[] = [];
|
|
1092
1120
|
for (const child of node.children) {
|
|
@@ -1096,7 +1124,7 @@ function appendInlineSegments(
|
|
|
1096
1124
|
document,
|
|
1097
1125
|
cursor,
|
|
1098
1126
|
promoteSecondaryStoryTextBoxes,
|
|
1099
|
-
|
|
1127
|
+
refHyperlinkHref ?? hyperlinkHref,
|
|
1100
1128
|
cullBuild,
|
|
1101
1129
|
);
|
|
1102
1130
|
cursor = result.nextCursor;
|
|
@@ -1159,7 +1187,7 @@ function appendComplexPreviewSegment(
|
|
|
1159
1187
|
start: number,
|
|
1160
1188
|
label: string,
|
|
1161
1189
|
detail: string,
|
|
1162
|
-
extras: { previewMediaId?: string } = {},
|
|
1190
|
+
extras: { previewMediaId?: string; parsedChartId?: string } = {},
|
|
1163
1191
|
): { nextCursor: number; lockedFragmentIds: string[] } {
|
|
1164
1192
|
paragraph.segments.push({
|
|
1165
1193
|
segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
|
|
@@ -1171,6 +1199,7 @@ function appendComplexPreviewSegment(
|
|
|
1171
1199
|
label,
|
|
1172
1200
|
detail,
|
|
1173
1201
|
...(extras.previewMediaId ? { previewMediaId: extras.previewMediaId } : {}),
|
|
1202
|
+
...(extras.parsedChartId ? { parsedChartId: extras.parsedChartId } : {}),
|
|
1174
1203
|
state: "locked-preserve-only",
|
|
1175
1204
|
});
|
|
1176
1205
|
return { nextCursor: start + 1, lockedFragmentIds: [] };
|