@beyondwork/docx-react-component 1.0.50 → 1.0.52
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/README.md +8 -5
- package/package.json +40 -29
- package/src/api/public-types.ts +9 -0
- package/src/runtime/layout/layout-engine-version.ts +42 -1
- package/src/runtime/layout/layout-invalidation.ts +62 -5
- package/src/runtime/layout/page-graph.ts +94 -1
- package/src/runtime/render/index.ts +7 -0
- package/src/runtime/render/render-frame-diff.ts +298 -0
- package/src/runtime/render/render-frame-types.ts +8 -1
- package/src/runtime/render/render-kernel.ts +40 -10
- package/src/runtime/selection/cursor-ops.ts +202 -0
- package/src/runtime/selection/index.ts +91 -0
- package/src/runtime/surface-projection.ts +10 -1
- package/src/runtime/theme-color-resolver.ts +46 -0
- package/src/ui/WordReviewEditor.tsx +3 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +344 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +344 -0
- package/src/ui-tailwind/chart/render/number-format.ts +287 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plot-area layout: partition the canvas into title / legend / axis /
|
|
3
|
+
* plot rectangles (Stage 3A, pure math).
|
|
4
|
+
*
|
|
5
|
+
* Call shape:
|
|
6
|
+
*
|
|
7
|
+
* layoutPlotArea({ w, h }, model, theme) → PlotAreaLayout
|
|
8
|
+
*
|
|
9
|
+
* Algorithm (Word's observed order):
|
|
10
|
+
* 1. Reserve title band at top (if `model.title` present and not overlaid).
|
|
11
|
+
* 2. Reserve legend band on the declared side (b/t/l/r/tr).
|
|
12
|
+
* 3. Reserve Y-axis band on the left (and right if secondary axis).
|
|
13
|
+
* 4. Reserve X-axis band on the bottom.
|
|
14
|
+
* 5. Remaining rectangle is the plot area.
|
|
15
|
+
*
|
|
16
|
+
* **Text measurement is a fixed-stub at Slice 3A.** `measureTextWidth`
|
|
17
|
+
* and `measureTextHeight` use a deterministic avg-glyph / line-height
|
|
18
|
+
* approximation so the layout math is unit-testable without a browser.
|
|
19
|
+
* Slice 3B swaps in the real `measureText(text, txPr, theme)` helper
|
|
20
|
+
* that calls `ctx.measureText` (browser) or an empirical LRU (SSR) —
|
|
21
|
+
* the stub keeps the public API stable so Slice 3B is a pure
|
|
22
|
+
* implementation swap.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ChartModel } from "../../../io/ooxml/chart/types.ts";
|
|
26
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
27
|
+
import {
|
|
28
|
+
generateCategoryTicks,
|
|
29
|
+
generateValueTicks,
|
|
30
|
+
type TickResult,
|
|
31
|
+
} from "./axis-layout.ts";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Public API
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface Rect {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
w: number;
|
|
41
|
+
h: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PlotAreaLayout {
|
|
45
|
+
plotRect: Rect;
|
|
46
|
+
titleRect?: Rect;
|
|
47
|
+
legendRect?: Rect;
|
|
48
|
+
xAxisRect?: Rect;
|
|
49
|
+
yAxisRect?: Rect;
|
|
50
|
+
secondaryYAxisRect?: Rect;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CanvasRect {
|
|
54
|
+
w: number;
|
|
55
|
+
h: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Partition the canvas into labelled sub-rectangles. The returned
|
|
60
|
+
* `plotRect` is the rectangle the chart-type renderer draws into.
|
|
61
|
+
*
|
|
62
|
+
* The current implementation uses the fixed-stub text measurement
|
|
63
|
+
* (`GLYPH_WIDTH_PX` × font size, `LINE_HEIGHT_RATIO` × font size). Slice
|
|
64
|
+
* 3B replaces those constants with a call-through to the real
|
|
65
|
+
* font-metrics helper.
|
|
66
|
+
*/
|
|
67
|
+
export function layoutPlotArea(
|
|
68
|
+
canvas: CanvasRect,
|
|
69
|
+
model: ChartModel,
|
|
70
|
+
theme: ResolvedTheme | undefined,
|
|
71
|
+
): PlotAreaLayout {
|
|
72
|
+
void theme; // consumed by Slice 3B's real font-metrics wrapper.
|
|
73
|
+
|
|
74
|
+
let top = 0;
|
|
75
|
+
let bottom = canvas.h;
|
|
76
|
+
let left = 0;
|
|
77
|
+
let right = canvas.w;
|
|
78
|
+
|
|
79
|
+
const out: PlotAreaLayout = {
|
|
80
|
+
plotRect: { x: 0, y: 0, w: canvas.w, h: canvas.h },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// 1. Title band (top).
|
|
84
|
+
if (model.kind !== "unsupported" && model.title && !model.title.overlay) {
|
|
85
|
+
const titleHeight = measureTitleHeight(model.title.text ?? "");
|
|
86
|
+
out.titleRect = { x: 0, y: 0, w: canvas.w, h: titleHeight };
|
|
87
|
+
top += titleHeight + BAND_GAP_PX;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Legend band — on the side indicated by `legend.position`.
|
|
91
|
+
if (model.kind !== "unsupported" && model.legend && !model.legend.overlay) {
|
|
92
|
+
const entryCount = countLegendEntries(model);
|
|
93
|
+
const { w: legendW, h: legendH } = measureLegendBox(entryCount);
|
|
94
|
+
switch (model.legend.position) {
|
|
95
|
+
case "t":
|
|
96
|
+
out.legendRect = { x: 0, y: top, w: canvas.w, h: legendH };
|
|
97
|
+
top += legendH + BAND_GAP_PX;
|
|
98
|
+
break;
|
|
99
|
+
case "b":
|
|
100
|
+
out.legendRect = {
|
|
101
|
+
x: 0,
|
|
102
|
+
y: bottom - legendH,
|
|
103
|
+
w: canvas.w,
|
|
104
|
+
h: legendH,
|
|
105
|
+
};
|
|
106
|
+
bottom -= legendH + BAND_GAP_PX;
|
|
107
|
+
break;
|
|
108
|
+
case "l":
|
|
109
|
+
out.legendRect = { x: left, y: top, w: legendW, h: bottom - top };
|
|
110
|
+
left += legendW + BAND_GAP_PX;
|
|
111
|
+
break;
|
|
112
|
+
case "r":
|
|
113
|
+
case "tr":
|
|
114
|
+
default:
|
|
115
|
+
out.legendRect = {
|
|
116
|
+
x: right - legendW,
|
|
117
|
+
y: top,
|
|
118
|
+
w: legendW,
|
|
119
|
+
h: bottom - top,
|
|
120
|
+
};
|
|
121
|
+
right -= legendW + BAND_GAP_PX;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. Axis bands. Only cartesian families (bar/line/area/scatter/bubble/
|
|
127
|
+
// combo) reserve axis space; pie/doughnut/unsupported skip this entirely.
|
|
128
|
+
const axes = pickAxes(model);
|
|
129
|
+
if (axes) {
|
|
130
|
+
// Y-axis on the left: width = max(tick label width) + axis-title
|
|
131
|
+
// rotated height.
|
|
132
|
+
const yTickLabels = axisTickLabels(axes.y);
|
|
133
|
+
const yWidth =
|
|
134
|
+
maxLabelWidth(yTickLabels) + (axes.yTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
135
|
+
out.yAxisRect = { x: left, y: top, w: yWidth, h: bottom - top };
|
|
136
|
+
left += yWidth + BAND_GAP_PX;
|
|
137
|
+
|
|
138
|
+
// Secondary Y-axis on the right.
|
|
139
|
+
if (axes.secondaryY) {
|
|
140
|
+
const y2TickLabels = axisTickLabels(axes.secondaryY);
|
|
141
|
+
const y2Width =
|
|
142
|
+
maxLabelWidth(y2TickLabels) + (axes.secondaryYTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
143
|
+
out.secondaryYAxisRect = {
|
|
144
|
+
x: right - y2Width,
|
|
145
|
+
y: top,
|
|
146
|
+
w: y2Width,
|
|
147
|
+
h: bottom - top,
|
|
148
|
+
};
|
|
149
|
+
right -= y2Width + BAND_GAP_PX;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// X-axis on the bottom: height = label height + axis-title band.
|
|
153
|
+
const xLabelHeight = X_AXIS_LABEL_HEIGHT_PX;
|
|
154
|
+
const xHeight = xLabelHeight + (axes.xTitle ? AXIS_TITLE_BAND_PX : 0);
|
|
155
|
+
out.xAxisRect = {
|
|
156
|
+
x: left,
|
|
157
|
+
y: bottom - xHeight,
|
|
158
|
+
w: right - left,
|
|
159
|
+
h: xHeight,
|
|
160
|
+
};
|
|
161
|
+
bottom -= xHeight + BAND_GAP_PX;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
out.plotRect = {
|
|
165
|
+
x: left,
|
|
166
|
+
y: top,
|
|
167
|
+
w: Math.max(0, right - left),
|
|
168
|
+
h: Math.max(0, bottom - top),
|
|
169
|
+
};
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Text measurement — fixed stub (Slice 3B replaces with real metrics)
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Approximate glyph width in pixels at font size 1. The real value
|
|
179
|
+
* depends on font + character; Calibri averages ~0.44 em for narrow
|
|
180
|
+
* body text, but chart labels tend to be digits which are monospace-
|
|
181
|
+
* like. 0.55 em is a conservative average that matches Word's default
|
|
182
|
+
* axis-label font (Calibri 10pt ≈ 13.3px) well enough for layout
|
|
183
|
+
* reservation. Replaced in Slice 3B.
|
|
184
|
+
*/
|
|
185
|
+
const GLYPH_WIDTH_EM = 0.55;
|
|
186
|
+
|
|
187
|
+
const LINE_HEIGHT_RATIO = 1.2;
|
|
188
|
+
const BAND_GAP_PX = 4;
|
|
189
|
+
const AXIS_TITLE_BAND_PX = 14;
|
|
190
|
+
const X_AXIS_LABEL_HEIGHT_PX = 14;
|
|
191
|
+
const DEFAULT_AXIS_FONT_PX = 10;
|
|
192
|
+
const DEFAULT_TITLE_FONT_PX = 14;
|
|
193
|
+
const DEFAULT_LEGEND_FONT_PX = 10;
|
|
194
|
+
const LEGEND_SWATCH_WIDTH_PX = 16;
|
|
195
|
+
const LEGEND_SWATCH_GAP_PX = 6;
|
|
196
|
+
const LEGEND_ENTRY_GAP_PX = 12;
|
|
197
|
+
const LEGEND_MIN_WIDTH_PX = 80;
|
|
198
|
+
|
|
199
|
+
function measureTitleHeight(text: string): number {
|
|
200
|
+
if (!text) return 0;
|
|
201
|
+
return DEFAULT_TITLE_FONT_PX * LINE_HEIGHT_RATIO;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function measureLegendBox(entryCount: number): { w: number; h: number } {
|
|
205
|
+
if (entryCount <= 0) return { w: 0, h: 0 };
|
|
206
|
+
const avgLabelChars = 8;
|
|
207
|
+
const entryWidth =
|
|
208
|
+
LEGEND_SWATCH_WIDTH_PX +
|
|
209
|
+
LEGEND_SWATCH_GAP_PX +
|
|
210
|
+
GLYPH_WIDTH_EM * DEFAULT_LEGEND_FONT_PX * avgLabelChars;
|
|
211
|
+
// Reserve a one-column stack: width = entryWidth, height = N lines.
|
|
212
|
+
return {
|
|
213
|
+
w: Math.max(LEGEND_MIN_WIDTH_PX, entryWidth + LEGEND_ENTRY_GAP_PX),
|
|
214
|
+
h: entryCount * DEFAULT_LEGEND_FONT_PX * LINE_HEIGHT_RATIO,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function maxLabelWidth(labels: ReadonlyArray<string>): number {
|
|
219
|
+
if (labels.length === 0) return 0;
|
|
220
|
+
let maxChars = 0;
|
|
221
|
+
for (const label of labels) {
|
|
222
|
+
if (label.length > maxChars) maxChars = label.length;
|
|
223
|
+
}
|
|
224
|
+
return maxChars * GLYPH_WIDTH_EM * DEFAULT_AXIS_FONT_PX;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Model introspection helpers
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
interface AxisBundle {
|
|
232
|
+
y: TickResult;
|
|
233
|
+
yTitle: boolean;
|
|
234
|
+
x?: TickResult;
|
|
235
|
+
xTitle: boolean;
|
|
236
|
+
secondaryY?: TickResult;
|
|
237
|
+
secondaryYTitle: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Inspect the model and produce tick lists for each axis position so
|
|
242
|
+
* the layout can reserve space based on the widest label. Pie /
|
|
243
|
+
* doughnut / unsupported return null (no axis bands).
|
|
244
|
+
*/
|
|
245
|
+
function pickAxes(model: ChartModel): AxisBundle | null {
|
|
246
|
+
switch (model.kind) {
|
|
247
|
+
case "bar":
|
|
248
|
+
case "line":
|
|
249
|
+
case "area": {
|
|
250
|
+
const yTicks = axisTicks(model.valueAxis);
|
|
251
|
+
const y2Ticks = model.secondaryValueAxis
|
|
252
|
+
? axisTicks(model.secondaryValueAxis)
|
|
253
|
+
: undefined;
|
|
254
|
+
const bundle: AxisBundle = {
|
|
255
|
+
y: yTicks,
|
|
256
|
+
yTitle: !!model.valueAxis.title,
|
|
257
|
+
xTitle: model.categoryAxis.kind === "category"
|
|
258
|
+
? !!model.categoryAxis.title
|
|
259
|
+
: !!model.categoryAxis.title,
|
|
260
|
+
secondaryYTitle: !!model.secondaryValueAxis?.title,
|
|
261
|
+
};
|
|
262
|
+
if (y2Ticks) bundle.secondaryY = y2Ticks;
|
|
263
|
+
return bundle;
|
|
264
|
+
}
|
|
265
|
+
case "scatter":
|
|
266
|
+
case "bubble": {
|
|
267
|
+
return {
|
|
268
|
+
y: axisTicks(model.yAxis),
|
|
269
|
+
yTitle: !!model.yAxis.title,
|
|
270
|
+
x: axisTicks(model.xAxis),
|
|
271
|
+
xTitle: !!model.xAxis.title,
|
|
272
|
+
secondaryYTitle: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
case "combo": {
|
|
276
|
+
// Use the first group's axes as representative.
|
|
277
|
+
const first = model.groups[0];
|
|
278
|
+
if (!first) return null;
|
|
279
|
+
return pickAxes(first);
|
|
280
|
+
}
|
|
281
|
+
case "pie":
|
|
282
|
+
case "unsupported":
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function axisTicks(axis: {
|
|
288
|
+
kind: string;
|
|
289
|
+
min?: number;
|
|
290
|
+
max?: number;
|
|
291
|
+
majorUnit?: number;
|
|
292
|
+
categoryLabels?: ReadonlyArray<string>;
|
|
293
|
+
}): TickResult {
|
|
294
|
+
if (axis.kind === "category") {
|
|
295
|
+
const cat = generateCategoryTicks({
|
|
296
|
+
labels: axis.categoryLabels ?? [],
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
major: cat.ticks.map((t) => t.index),
|
|
300
|
+
minor: [],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const min = axis.min ?? 0;
|
|
304
|
+
const max = axis.max ?? 1;
|
|
305
|
+
return generateValueTicks({
|
|
306
|
+
min,
|
|
307
|
+
max,
|
|
308
|
+
...(axis.majorUnit !== undefined ? { majorUnit: axis.majorUnit } : {}),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Produce string labels for an axis's major ticks. Category axis uses
|
|
314
|
+
* the source labels directly; value axis stringifies numeric positions.
|
|
315
|
+
* Real number-format handling lands in Slice 4G via `number-format.ts`;
|
|
316
|
+
* the fixed-width stub here is sufficient for plot-area reservation
|
|
317
|
+
* (within ~1 glyph-width of the real rendered size).
|
|
318
|
+
*/
|
|
319
|
+
function axisTickLabels(ticks: TickResult): string[] {
|
|
320
|
+
return ticks.major.map((t) => String(t));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function countLegendEntries(model: ChartModel): number {
|
|
324
|
+
switch (model.kind) {
|
|
325
|
+
case "bar":
|
|
326
|
+
case "line":
|
|
327
|
+
case "area":
|
|
328
|
+
case "scatter":
|
|
329
|
+
case "bubble":
|
|
330
|
+
return model.series.length;
|
|
331
|
+
case "pie": {
|
|
332
|
+
// Pie legends show one entry per slice.
|
|
333
|
+
const first = model.series[0];
|
|
334
|
+
return first ? first.values.length : 0;
|
|
335
|
+
}
|
|
336
|
+
case "combo": {
|
|
337
|
+
let total = 0;
|
|
338
|
+
for (const g of model.groups) total += g.series.length;
|
|
339
|
+
return total;
|
|
340
|
+
}
|
|
341
|
+
case "unsupported":
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel-style number-format engine for chart tick labels + data labels
|
|
3
|
+
* (Stage 3A, pure math).
|
|
4
|
+
*
|
|
5
|
+
* Supports the top ~20 real-world format codes:
|
|
6
|
+
* - Digit placeholders: `0` (required digit), `#` (optional digit).
|
|
7
|
+
* - Decimal point: `.`.
|
|
8
|
+
* - Thousands separator: `,` between digit placeholders.
|
|
9
|
+
* - Percent: `%` — scales value by 100 and appends `%`.
|
|
10
|
+
* - Currency: `$` and quoted literals like `"$"`.
|
|
11
|
+
* - Scientific: `0.00E+00` style.
|
|
12
|
+
* - Date tokens: `yyyy` `yy` `mm` `m` `mmm` `mmmm` `dd` `d`.
|
|
13
|
+
* - Time tokens: `hh` `h` `mm` (contextual) `ss`.
|
|
14
|
+
* - `General`: fall back to JavaScript's default `toString()`.
|
|
15
|
+
* - `@`: text placeholder — returns the value verbatim (stringified).
|
|
16
|
+
*
|
|
17
|
+
* Out of scope (deferred post-v1):
|
|
18
|
+
* - Conditional formats (`[>100]...;...`).
|
|
19
|
+
* - Locale-specific formats (`[$-409]`).
|
|
20
|
+
* - Color tokens (`[Red]`, `[Blue]`).
|
|
21
|
+
* - Fraction formats (`# ?/?`).
|
|
22
|
+
*
|
|
23
|
+
* Unknown / malformed codes fall through to `String(value)` so the
|
|
24
|
+
* renderer never produces `NaN` or an exception at render time.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Public entry point
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export function formatNumber(value: number, code: string | undefined): string {
|
|
32
|
+
if (code === undefined || code === "" || code.toLowerCase() === "general") {
|
|
33
|
+
return Number.isFinite(value) ? String(value) : "";
|
|
34
|
+
}
|
|
35
|
+
if (code === "@") return String(value);
|
|
36
|
+
|
|
37
|
+
if (!Number.isFinite(value)) return "";
|
|
38
|
+
|
|
39
|
+
// Date/time codes contain y/m/d/h/s letter tokens outside literals.
|
|
40
|
+
// Detect and route to the date formatter.
|
|
41
|
+
if (isDateFormatCode(code)) {
|
|
42
|
+
return formatDate(value, code);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Scientific: "0.00E+00" / "0E+0"
|
|
46
|
+
if (/E[+-]?0/iu.test(code)) {
|
|
47
|
+
return formatScientific(value, code);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return formatDecimal(value, code);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Decimal formatter
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function formatDecimal(value: number, code: string): string {
|
|
58
|
+
// Extract percent, currency prefix, thousands separator, and decimal
|
|
59
|
+
// precision from the format code. We scan for digit placeholders
|
|
60
|
+
// (0, #) and the decimal point.
|
|
61
|
+
let working = value;
|
|
62
|
+
const tokens = tokenizeLiterals(code);
|
|
63
|
+
|
|
64
|
+
// Find a sample of the digit-placeholder substring to measure precision.
|
|
65
|
+
const digitRun = tokens
|
|
66
|
+
.filter((t) => t.kind === "digits")
|
|
67
|
+
.map((t) => t.value)
|
|
68
|
+
.join("");
|
|
69
|
+
|
|
70
|
+
// Percent is a separate token from the tokenizer; scale accordingly.
|
|
71
|
+
const hasPercent = tokens.some((t) => t.kind === "percent");
|
|
72
|
+
if (hasPercent) working *= 100;
|
|
73
|
+
|
|
74
|
+
const decimalIdx = digitRun.indexOf(".");
|
|
75
|
+
const fractionDigits = decimalIdx >= 0 ? digitRun.length - decimalIdx - 1 : 0;
|
|
76
|
+
const useThousands = /,(?=[0#])/.test(digitRun);
|
|
77
|
+
const absWorking = Math.abs(working);
|
|
78
|
+
|
|
79
|
+
const [intPart, fracPart] = absWorking
|
|
80
|
+
.toFixed(Math.max(0, fractionDigits))
|
|
81
|
+
.split(".");
|
|
82
|
+
|
|
83
|
+
const intGrouped = useThousands ? groupThousands(intPart!) : intPart!;
|
|
84
|
+
const formatted =
|
|
85
|
+
fracPart !== undefined && fracPart.length > 0
|
|
86
|
+
? `${intGrouped}.${fracPart}`
|
|
87
|
+
: intGrouped;
|
|
88
|
+
const signed = working < 0 ? `-${formatted}` : formatted;
|
|
89
|
+
|
|
90
|
+
// Stitch back prefix/suffix literals around the numeric body. We
|
|
91
|
+
// replace the first run of digit placeholders with the formatted
|
|
92
|
+
// number; other literals (currency symbols, spaces, "%") stay.
|
|
93
|
+
let produced = false;
|
|
94
|
+
const parts: string[] = [];
|
|
95
|
+
for (const t of tokens) {
|
|
96
|
+
if (t.kind === "digits") {
|
|
97
|
+
if (!produced) {
|
|
98
|
+
parts.push(signed);
|
|
99
|
+
produced = true;
|
|
100
|
+
}
|
|
101
|
+
// drop subsequent digit runs — they were part of the numeric
|
|
102
|
+
// template we already substituted.
|
|
103
|
+
} else if (t.kind === "literal") {
|
|
104
|
+
parts.push(t.value);
|
|
105
|
+
} else if (t.kind === "percent") {
|
|
106
|
+
parts.push("%");
|
|
107
|
+
} else if (t.kind === "currency") {
|
|
108
|
+
parts.push(t.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!produced) parts.push(signed);
|
|
112
|
+
return parts.join("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function groupThousands(intPart: string): string {
|
|
116
|
+
return intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Scientific formatter
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function formatScientific(value: number, code: string): string {
|
|
124
|
+
const match = /(0+)(\.0+)?[eE]([+-]?)(0+)/u.exec(code);
|
|
125
|
+
if (!match) return value.toExponential();
|
|
126
|
+
const mantissaInt = match[1]!.length;
|
|
127
|
+
const mantissaFrac = match[2] ? match[2].length - 1 : 0;
|
|
128
|
+
const signToken = match[3] ?? "";
|
|
129
|
+
const fracDigits = mantissaInt + mantissaFrac - 1;
|
|
130
|
+
const exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
|
|
131
|
+
const mantissa = value / Math.pow(10, exp);
|
|
132
|
+
const mantissaStr = mantissa.toFixed(Math.max(0, fracDigits));
|
|
133
|
+
const sign = signToken === "+" ? (exp >= 0 ? "+" : "-") : exp < 0 ? "-" : "";
|
|
134
|
+
const expStr = String(Math.abs(exp)).padStart(match[4]!.length, "0");
|
|
135
|
+
return `${mantissaStr}E${sign}${expStr}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Date formatter
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const DATE_CODE_PATTERN = /\b(yyyy|yy|mmmm|mmm|mm|m|dd|d|hh|h|ss)\b/iu;
|
|
143
|
+
|
|
144
|
+
function isDateFormatCode(code: string): boolean {
|
|
145
|
+
// Strip quoted literals to avoid false positives on quoted text.
|
|
146
|
+
const stripped = code.replace(/"[^"]*"/g, "");
|
|
147
|
+
return DATE_CODE_PATTERN.test(stripped);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatDate(serial: number, code: string): string {
|
|
151
|
+
// Excel serial → Date. Epoch 1899-12-30.
|
|
152
|
+
const ms = (serial - 25569) * 86_400_000;
|
|
153
|
+
const date = new Date(ms);
|
|
154
|
+
const y = date.getUTCFullYear();
|
|
155
|
+
const m = date.getUTCMonth() + 1;
|
|
156
|
+
const d = date.getUTCDate();
|
|
157
|
+
const hh = date.getUTCHours();
|
|
158
|
+
const mi = date.getUTCMinutes();
|
|
159
|
+
const ss = date.getUTCSeconds();
|
|
160
|
+
|
|
161
|
+
const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
162
|
+
const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
163
|
+
|
|
164
|
+
// Parse the format by splitting on literal-vs-token boundaries.
|
|
165
|
+
// Process longest tokens first (yyyy before yy, mmmm before mmm/mm, etc.).
|
|
166
|
+
let out = "";
|
|
167
|
+
let i = 0;
|
|
168
|
+
let sawTime = false; // `mm` after h/hh means minutes
|
|
169
|
+
const lowered = code.toLowerCase();
|
|
170
|
+
while (i < code.length) {
|
|
171
|
+
const ch = code[i]!;
|
|
172
|
+
if (ch === '"') {
|
|
173
|
+
const end = code.indexOf('"', i + 1);
|
|
174
|
+
if (end === -1) { out += code.slice(i + 1); break; }
|
|
175
|
+
out += code.slice(i + 1, end);
|
|
176
|
+
i = end + 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Try 4/3/2/1-char tokens at this position.
|
|
180
|
+
const tok4 = lowered.slice(i, i + 4);
|
|
181
|
+
const tok3 = lowered.slice(i, i + 3);
|
|
182
|
+
const tok2 = lowered.slice(i, i + 2);
|
|
183
|
+
const tok1 = lowered.slice(i, i + 1);
|
|
184
|
+
if (tok4 === "yyyy") { out += String(y).padStart(4, "0"); i += 4; continue; }
|
|
185
|
+
if (tok4 === "mmmm") { out += MONTHS_LONG[m - 1]!; i += 4; continue; }
|
|
186
|
+
if (tok3 === "mmm") { out += MONTHS_SHORT[m - 1]!; i += 3; continue; }
|
|
187
|
+
if (tok2 === "yy") { out += String(y).slice(-2); i += 2; continue; }
|
|
188
|
+
if (tok2 === "hh") { out += pad2(hh); sawTime = true; i += 2; continue; }
|
|
189
|
+
if (tok2 === "mm") {
|
|
190
|
+
if (sawTime) {
|
|
191
|
+
out += pad2(mi);
|
|
192
|
+
sawTime = false;
|
|
193
|
+
} else {
|
|
194
|
+
out += pad2(m);
|
|
195
|
+
}
|
|
196
|
+
i += 2;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (tok2 === "ss") { out += pad2(ss); i += 2; continue; }
|
|
200
|
+
if (tok2 === "dd") { out += pad2(d); i += 2; continue; }
|
|
201
|
+
if (tok1 === "h") { out += String(hh); sawTime = true; i += 1; continue; }
|
|
202
|
+
if (tok1 === "m") { out += sawTime ? String(mi) : String(m); if (sawTime) sawTime = false; i += 1; continue; }
|
|
203
|
+
if (tok1 === "d") { out += String(d); i += 1; continue; }
|
|
204
|
+
if (tok1 === "y") { out += String(y); i += 1; continue; }
|
|
205
|
+
out += ch;
|
|
206
|
+
i += 1;
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pad2(n: number): string {
|
|
212
|
+
return n < 10 ? `0${n}` : String(n);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Literal tokenization for decimal format codes
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
interface FormatToken {
|
|
220
|
+
kind: "digits" | "literal" | "percent" | "currency";
|
|
221
|
+
value: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Split a decimal format code into a stream of tokens so the formatter
|
|
226
|
+
* can re-stitch prefix/suffix literals around the formatted number.
|
|
227
|
+
*
|
|
228
|
+
* Examples:
|
|
229
|
+
* `"$#,##0.00"` → [{currency "$"}, {digits "#,##0.00"}]
|
|
230
|
+
* `"0.00%"` → [{digits "0.00"}, {percent}]
|
|
231
|
+
* `"\"Total: \"0"` → [{literal "Total: "}, {digits "0"}]
|
|
232
|
+
*/
|
|
233
|
+
function tokenizeLiterals(code: string): FormatToken[] {
|
|
234
|
+
const out: FormatToken[] = [];
|
|
235
|
+
let i = 0;
|
|
236
|
+
let digitBuf = "";
|
|
237
|
+
const flushDigits = () => {
|
|
238
|
+
if (digitBuf) {
|
|
239
|
+
out.push({ kind: "digits", value: digitBuf });
|
|
240
|
+
digitBuf = "";
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
while (i < code.length) {
|
|
244
|
+
const ch = code[i]!;
|
|
245
|
+
if (ch === '"') {
|
|
246
|
+
flushDigits();
|
|
247
|
+
const end = code.indexOf('"', i + 1);
|
|
248
|
+
const lit = end === -1 ? code.slice(i + 1) : code.slice(i + 1, end);
|
|
249
|
+
out.push({ kind: "literal", value: lit });
|
|
250
|
+
i = end === -1 ? code.length : end + 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (ch === "\\") {
|
|
254
|
+
flushDigits();
|
|
255
|
+
if (i + 1 < code.length) {
|
|
256
|
+
out.push({ kind: "literal", value: code[i + 1]! });
|
|
257
|
+
i += 2;
|
|
258
|
+
} else {
|
|
259
|
+
i += 1;
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (ch === "$") {
|
|
264
|
+
flushDigits();
|
|
265
|
+
out.push({ kind: "currency", value: "$" });
|
|
266
|
+
i += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (ch === "%") {
|
|
270
|
+
flushDigits();
|
|
271
|
+
out.push({ kind: "percent", value: "%" });
|
|
272
|
+
i += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (ch === "0" || ch === "#" || ch === "." || ch === "," || ch === " ") {
|
|
276
|
+
digitBuf += ch;
|
|
277
|
+
i += 1;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// Unknown char — treat as literal.
|
|
281
|
+
flushDigits();
|
|
282
|
+
out.push({ kind: "literal", value: ch });
|
|
283
|
+
i += 1;
|
|
284
|
+
}
|
|
285
|
+
flushDigits();
|
|
286
|
+
return out;
|
|
287
|
+
}
|