@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
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Line chart renderer (Stage 4 Slice 4B).
3
+ *
4
+ * Handles straight + smoothed + markers + gap handling. Respects
5
+ * `dispBlanksAs` (B3):
6
+ * - `"gap"`: break the polyline at null indices — emit separate path
7
+ * segments on either side of each gap.
8
+ * - `"zero"`: substitute null → 0 before rendering.
9
+ * - `"span"`: connect across null indices as if they were absent
10
+ * (the neighbors' chord closes the gap).
11
+ *
12
+ * Smoothing uses the in-tree Catmull-Rom → cubic-Bézier helper from
13
+ * `smooth-curve.ts` (Slice 4B). Per-series `smooth` override trumps the
14
+ * model-level `smooth` flag; same for `marker`.
15
+ *
16
+ * Stacked / percentStacked groupings sum values per category to derive
17
+ * cumulative y coordinates, matching Word's behavior.
18
+ */
19
+
20
+ import React from "react";
21
+ import { composeSeriesColor } from "../../../io/ooxml/chart/compose-series-color.ts";
22
+ import type {
23
+ LineChartModel,
24
+ LineSeries,
25
+ MarkerSpec,
26
+ } from "../../../io/ooxml/chart/types.ts";
27
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
28
+ import type { PlotAreaLayout } from "../layout/plot-area.ts";
29
+ import { smoothPath, type Point } from "./smooth-curve.ts";
30
+ import { useProgressiveCount } from "./progressive-render.ts";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Public API
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface LineChartProps {
37
+ model: LineChartModel;
38
+ layout: PlotAreaLayout;
39
+ theme: ResolvedTheme | undefined;
40
+ }
41
+
42
+ function LineChartImpl({ model, layout, theme }: LineChartProps): React.ReactElement {
43
+ const plot = layout.plotRect;
44
+ const { valueMin, valueMax } = computeValueRange(model);
45
+ const categoryCount = Math.max(
46
+ 1,
47
+ model.categoryAxis.kind === "category"
48
+ ? model.categoryAxis.categoryLabels.length
49
+ : model.series[0]?.values.length ?? 1,
50
+ );
51
+ const visibleCategories = useProgressiveCount(categoryCount);
52
+ const slotWidth = plot.w / categoryCount;
53
+ const valueSpan = Math.max(1e-9, valueMax - valueMin);
54
+
55
+ /** Map (categoryIdx, value) → plot-coordinate point. */
56
+ const toPoint = (c: number, v: number): Point => {
57
+ const x = plot.x + (c + 0.5) * slotWidth;
58
+ const frac = (v - valueMin) / valueSpan;
59
+ const y = plot.y + plot.h - frac * plot.h;
60
+ return [x, y];
61
+ };
62
+
63
+ const lines: React.ReactElement[] = [];
64
+ const markers: React.ReactElement[] = [];
65
+
66
+ // Pre-compute per-series values (handling stacking).
67
+ const effectiveValues = computeEffectiveValues(model);
68
+
69
+ for (let s = 0; s < model.series.length; s++) {
70
+ const series = model.series[s]!;
71
+ const values = effectiveValues[s]!;
72
+ const color = composeSeriesColor(model, theme ?? { colors: {} }, s);
73
+ const smooth = series.smooth ?? model.smooth;
74
+ const showMarkers = (series.marker?.symbol ?? null) !== "none"
75
+ && ((series.marker !== undefined) || model.marker);
76
+
77
+ // Apply dispBlanksAs (B3); limit to visibleCategories for progressive render.
78
+ const visibleValues = visibleCategories < values.length
79
+ ? values.slice(0, visibleCategories)
80
+ : values;
81
+ const segments = applyDispBlanksAs(visibleValues, model.dispBlanksAs);
82
+
83
+ for (let segIdx = 0; segIdx < segments.length; segIdx++) {
84
+ const segment = segments[segIdx]!;
85
+ const points: Point[] = segment.map(({ c, v }) => toPoint(c, v));
86
+ if (points.length === 0) continue;
87
+ const d = smooth ? smoothPath(points) : polylinePath(points);
88
+ lines.push(
89
+ <path
90
+ key={`line-${s}-${segIdx}`}
91
+ d={d}
92
+ fill="none"
93
+ stroke={color}
94
+ strokeWidth={2}
95
+ data-role="line"
96
+ data-series-index={s}
97
+ data-segment-index={segIdx}
98
+ />,
99
+ );
100
+
101
+ if (showMarkers) {
102
+ for (const [i, pt] of points.entries()) {
103
+ markers.push(
104
+ renderMarker({
105
+ key: `m-${s}-${segIdx}-${i}`,
106
+ spec: series.marker,
107
+ point: pt,
108
+ color,
109
+ seriesIdx: s,
110
+ categoryIdx: segment[i]!.c,
111
+ }),
112
+ );
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ return (
119
+ <g data-role="line-chart" data-grouping={model.grouping}>
120
+ <g data-role="lines">{lines}</g>
121
+ <g data-role="markers">{markers}</g>
122
+ <g data-role="data-labels" />
123
+ </g>
124
+ );
125
+ }
126
+
127
+ export const LineChart = React.memo(
128
+ LineChartImpl,
129
+ (prev, next) =>
130
+ prev.model.rawXml === next.model.rawXml &&
131
+ prev.layout.plotRect.w === next.layout.plotRect.w &&
132
+ prev.layout.plotRect.h === next.layout.plotRect.h &&
133
+ prev.theme === next.theme,
134
+ );
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Path helpers
138
+ // ---------------------------------------------------------------------------
139
+
140
+ function polylinePath(points: ReadonlyArray<Point>): string {
141
+ if (points.length === 0) return "";
142
+ const first = points[0]!;
143
+ let d = `M ${first[0]} ${first[1]}`;
144
+ for (let i = 1; i < points.length; i++) {
145
+ const p = points[i]!;
146
+ d += ` L ${p[0]} ${p[1]}`;
147
+ }
148
+ return d;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Value extraction (B3 — dispBlanksAs)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ interface PointValue {
156
+ c: number;
157
+ v: number;
158
+ }
159
+
160
+ /**
161
+ * Convert a series' raw value array into contiguous segments based on
162
+ * `dispBlanksAs`. Each segment is a list of {categoryIdx, value} pairs
163
+ * that the renderer emits as a single path.
164
+ *
165
+ * - `"gap"`: nulls split the series into multiple segments (the path
166
+ * literally breaks at the gap).
167
+ * - `"zero"`: nulls become 0; single continuous segment.
168
+ * - `"span"`: nulls dropped; single segment connects neighbors directly.
169
+ */
170
+ function applyDispBlanksAs(
171
+ values: ReadonlyArray<number | null>,
172
+ mode: "gap" | "zero" | "span",
173
+ ): PointValue[][] {
174
+ switch (mode) {
175
+ case "gap": {
176
+ const segments: PointValue[][] = [];
177
+ let current: PointValue[] = [];
178
+ for (let c = 0; c < values.length; c++) {
179
+ const v = values[c];
180
+ if (v === null || v === undefined || !Number.isFinite(v)) {
181
+ if (current.length > 0) {
182
+ segments.push(current);
183
+ current = [];
184
+ }
185
+ continue;
186
+ }
187
+ current.push({ c, v });
188
+ }
189
+ if (current.length > 0) segments.push(current);
190
+ return segments;
191
+ }
192
+ case "zero": {
193
+ const out: PointValue[] = [];
194
+ for (let c = 0; c < values.length; c++) {
195
+ const v = values[c];
196
+ out.push({ c, v: v === null || v === undefined || !Number.isFinite(v) ? 0 : v });
197
+ }
198
+ return [out];
199
+ }
200
+ case "span": {
201
+ const out: PointValue[] = [];
202
+ for (let c = 0; c < values.length; c++) {
203
+ const v = values[c];
204
+ if (v === null || v === undefined || !Number.isFinite(v)) continue;
205
+ out.push({ c, v });
206
+ }
207
+ return out.length > 0 ? [out] : [];
208
+ }
209
+ }
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Stacking
214
+ // ---------------------------------------------------------------------------
215
+
216
+ function computeEffectiveValues(
217
+ model: LineChartModel,
218
+ ): Array<Array<number | null>> {
219
+ if (model.grouping === "standard") {
220
+ return model.series.map((s) => s.values.slice());
221
+ }
222
+ const categoryCount = Math.max(
223
+ 0,
224
+ model.series[0]?.values.length ?? 0,
225
+ );
226
+ const out: Array<Array<number | null>> = [];
227
+
228
+ if (model.grouping === "stacked") {
229
+ const running = new Array<number>(categoryCount).fill(0);
230
+ for (const series of model.series) {
231
+ const row: Array<number | null> = [];
232
+ for (let c = 0; c < categoryCount; c++) {
233
+ const v = series.values[c];
234
+ if (v === null || v === undefined || !Number.isFinite(v)) {
235
+ row.push(null);
236
+ } else {
237
+ running[c] = running[c]! + v;
238
+ row.push(running[c]!);
239
+ }
240
+ }
241
+ out.push(row);
242
+ }
243
+ return out;
244
+ }
245
+ // percentStacked
246
+ const totals = new Array<number>(categoryCount).fill(0);
247
+ for (const series of model.series) {
248
+ for (let c = 0; c < categoryCount; c++) {
249
+ const v = series.values[c];
250
+ if (v === null || v === undefined || !Number.isFinite(v)) continue;
251
+ totals[c] = totals[c]! + v;
252
+ }
253
+ }
254
+ const running = new Array<number>(categoryCount).fill(0);
255
+ for (const series of model.series) {
256
+ const row: Array<number | null> = [];
257
+ for (let c = 0; c < categoryCount; c++) {
258
+ const v = series.values[c];
259
+ const total = totals[c]!;
260
+ if (v === null || v === undefined || !Number.isFinite(v) || total === 0) {
261
+ row.push(null);
262
+ } else {
263
+ running[c] = running[c]! + (v / total) * 100;
264
+ row.push(running[c]!);
265
+ }
266
+ }
267
+ out.push(row);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function computeValueRange(model: LineChartModel): {
273
+ valueMin: number;
274
+ valueMax: number;
275
+ } {
276
+ if (model.valueAxis.min !== undefined && model.valueAxis.max !== undefined) {
277
+ return { valueMin: model.valueAxis.min, valueMax: model.valueAxis.max };
278
+ }
279
+ if (model.grouping === "percentStacked") {
280
+ return { valueMin: 0, valueMax: 100 };
281
+ }
282
+ const effective = computeEffectiveValues(model);
283
+ let min = 0;
284
+ let max = 0;
285
+ for (const row of effective) {
286
+ for (const v of row) {
287
+ if (v === null) continue;
288
+ if (v > max) max = v;
289
+ if (v < min) min = v;
290
+ }
291
+ }
292
+ return { valueMin: min, valueMax: max };
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Markers
297
+ // ---------------------------------------------------------------------------
298
+
299
+ interface MarkerRenderInput {
300
+ key: string;
301
+ spec: MarkerSpec | undefined;
302
+ point: Point;
303
+ color: string;
304
+ seriesIdx: number;
305
+ categoryIdx: number;
306
+ }
307
+
308
+ function renderMarker({ key, spec, point, color, seriesIdx, categoryIdx }: MarkerRenderInput): React.ReactElement {
309
+ const symbol = spec?.symbol && spec.symbol !== "auto" ? spec.symbol : "circle";
310
+ const size = spec?.size ?? 5;
311
+ const [cx, cy] = point;
312
+ const shared = {
313
+ fill: color,
314
+ stroke: color,
315
+ strokeWidth: 1,
316
+ "data-role": "marker",
317
+ "data-series-index": seriesIdx,
318
+ "data-category-index": categoryIdx,
319
+ } as const;
320
+
321
+ switch (symbol) {
322
+ case "square":
323
+ return <rect key={key} x={cx - size / 2} y={cy - size / 2} width={size} height={size} {...shared} />;
324
+ case "diamond":
325
+ return (
326
+ <polygon
327
+ key={key}
328
+ points={`${cx},${cy - size / 2} ${cx + size / 2},${cy} ${cx},${cy + size / 2} ${cx - size / 2},${cy}`}
329
+ {...shared}
330
+ />
331
+ );
332
+ case "triangle":
333
+ return (
334
+ <polygon
335
+ key={key}
336
+ points={`${cx},${cy - size / 2} ${cx + size / 2},${cy + size / 2} ${cx - size / 2},${cy + size / 2}`}
337
+ {...shared}
338
+ />
339
+ );
340
+ case "x":
341
+ case "plus": {
342
+ const half = size / 2;
343
+ const rot = symbol === "x" ? 45 : 0;
344
+ return (
345
+ <g key={key} transform={rot === 0 ? undefined : `rotate(${rot} ${cx} ${cy})`} data-role="marker" data-series-index={seriesIdx} data-category-index={categoryIdx}>
346
+ <line x1={cx - half} y1={cy} x2={cx + half} y2={cy} stroke={color} strokeWidth={1.5} />
347
+ <line x1={cx} y1={cy - half} x2={cx} y2={cy + half} stroke={color} strokeWidth={1.5} />
348
+ </g>
349
+ );
350
+ }
351
+ case "dash":
352
+ return <line key={key} x1={cx - size / 2} y1={cy} x2={cx + size / 2} y2={cy} stroke={color} strokeWidth={2} data-role="marker" data-series-index={seriesIdx} data-category-index={categoryIdx} />;
353
+ case "dot":
354
+ return <circle key={key} cx={cx} cy={cy} r={Math.max(1, size / 4)} {...shared} />;
355
+ case "star":
356
+ case "picture":
357
+ case "circle":
358
+ case "none":
359
+ default:
360
+ // Fallback: circle.
361
+ return <circle key={key} cx={cx} cy={cy} r={size / 2} {...shared} />;
362
+ }
363
+ }
@@ -3,7 +3,10 @@
3
3
  * (Stage 3A, pure math).
4
4
  *
5
5
  * Supports the top ~20 real-world format codes:
6
- * - Digit placeholders: `0` (required digit), `#` (optional digit).
6
+ * - Multi-section: `positive;negative;zero;text` `;` outside quoted
7
+ * literals separates sections; value sign selects the active section.
8
+ * - Digit placeholders: `0` (required digit), `#` (optional digit —
9
+ * suppresses trailing zeros and leading integer zero when unneeded).
7
10
  * - Decimal point: `.`.
8
11
  * - Thousands separator: `,` between digit placeholders.
9
12
  * - Percent: `%` — scales value by 100 and appends `%`.
@@ -24,6 +27,48 @@
24
27
  * renderer never produces `NaN` or an exception at render time.
25
28
  */
26
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Section splitting — C1
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Split a format code on `;` separators that appear outside quoted
36
+ * literal strings. Returns a 1-4 element array:
37
+ * [positive, negative?, zero?, text?]
38
+ *
39
+ * Semicolons inside `"..."` quoted strings are NOT treated as separators.
40
+ */
41
+ function splitFormatSections(code: string): string[] {
42
+ const sections: string[] = [];
43
+ let current = "";
44
+ let i = 0;
45
+ while (i < code.length) {
46
+ const ch = code[i]!;
47
+ if (ch === '"') {
48
+ // Include the entire quoted substring verbatim (no split inside).
49
+ const end = code.indexOf('"', i + 1);
50
+ if (end === -1) {
51
+ current += code.slice(i);
52
+ i = code.length;
53
+ } else {
54
+ current += code.slice(i, end + 1);
55
+ i = end + 1;
56
+ }
57
+ continue;
58
+ }
59
+ if (ch === ";") {
60
+ sections.push(current);
61
+ current = "";
62
+ i += 1;
63
+ continue;
64
+ }
65
+ current += ch;
66
+ i += 1;
67
+ }
68
+ sections.push(current);
69
+ return sections;
70
+ }
71
+
27
72
  // ---------------------------------------------------------------------------
28
73
  // Public entry point
29
74
  // ---------------------------------------------------------------------------
@@ -36,6 +81,20 @@ export function formatNumber(value: number, code: string | undefined): string {
36
81
 
37
82
  if (!Number.isFinite(value)) return "";
38
83
 
84
+ // C1: Multi-section format (positive;negative;zero;text). Split first,
85
+ // before routing to date/scientific/decimal, so each section is treated
86
+ // as an independent single-section code. Negative section receives
87
+ // Math.abs(value) so its template governs sign presentation (e.g. "(#,##0)").
88
+ const sections = splitFormatSections(code);
89
+ if (sections.length > 1) {
90
+ let idx = 0;
91
+ if (value < 0 && sections.length >= 2) idx = 1;
92
+ else if (value === 0 && sections.length >= 3) idx = 2;
93
+ const selectedSection = sections[idx]!;
94
+ const selectedValue = idx === 1 ? Math.abs(value) : value;
95
+ return formatNumber(selectedValue, selectedSection);
96
+ }
97
+
39
98
  // Date/time codes contain y/m/d/h/s letter tokens outside literals.
40
99
  // Detect and route to the date formatter.
41
100
  if (isDateFormatCode(code)) {
@@ -71,6 +130,10 @@ function formatDecimal(value: number, code: string): string {
71
130
  const hasPercent = tokens.some((t) => t.kind === "percent");
72
131
  if (hasPercent) working *= 100;
73
132
 
133
+ // C2: Track sign separately — the negative prefix is emitted at the very
134
+ // front of the stitch output (before any currency symbol or digit run).
135
+ const isNegative = working < 0;
136
+
74
137
  const decimalIdx = digitRun.indexOf(".");
75
138
  const fractionDigits = decimalIdx >= 0 ? digitRun.length - decimalIdx - 1 : 0;
76
139
  const useThousands = /,(?=[0#])/.test(digitRun);
@@ -80,35 +143,72 @@ function formatDecimal(value: number, code: string): string {
80
143
  .toFixed(Math.max(0, fractionDigits))
81
144
  .split(".");
82
145
 
146
+ // C3: Optional-digit (#) handling.
147
+ // Count trailing `#` chars in the fraction part of the format template.
148
+ // These are optional: trim the corresponding trailing zeros from the
149
+ // formatted fraction so "1.5" never renders as "1.50" under "#.##".
150
+ const fracFormat = decimalIdx >= 0 ? digitRun.slice(decimalIdx + 1) : "";
151
+ const trailingOptional = fracFormat.match(/#*$/)?.[0].length ?? 0;
152
+
153
+ // Suppress the leading "0" when the integer format is all-optional (#)
154
+ // and the integer value is actually zero (e.g. "#.##" on 0.5 → ".5").
155
+ const intFormat = decimalIdx >= 0 ? digitRun.slice(0, decimalIdx) : digitRun;
156
+ const suppressLeadingZero = !intFormat.includes("0") && intPart === "0";
157
+
158
+ let trimmedFrac = fracPart;
159
+ if (trailingOptional > 0 && trimmedFrac !== undefined) {
160
+ let trimCount = 0;
161
+ for (let fi = trimmedFrac.length - 1; fi >= 0 && trimCount < trailingOptional; fi--) {
162
+ if (trimmedFrac[fi] === "0") trimCount++;
163
+ else break;
164
+ }
165
+ if (trimCount > 0) trimmedFrac = trimmedFrac.slice(0, trimmedFrac.length - trimCount);
166
+ }
167
+
83
168
  const intGrouped = useThousands ? groupThousands(intPart!) : intPart!;
169
+ const intDisplay = suppressLeadingZero ? "" : intGrouped;
84
170
  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.
171
+ trimmedFrac !== undefined && trimmedFrac.length > 0
172
+ ? `${intDisplay}.${trimmedFrac}`
173
+ : intDisplay;
174
+
175
+ // C2 + C3: Stitch prefix/suffix literals around the numeric body.
176
+ // The negative sign is prepended exactly once, immediately before the
177
+ // first currency symbol or digit run so "$-1,234.50" never appears;
178
+ // the output is always "-$1,234.50".
179
+ let negativeEmitted = false;
93
180
  let produced = false;
94
181
  const parts: string[] = [];
95
182
  for (const t of tokens) {
96
- if (t.kind === "digits") {
183
+ if (t.kind === "currency") {
184
+ if (isNegative && !negativeEmitted) {
185
+ parts.push("-");
186
+ negativeEmitted = true;
187
+ }
188
+ parts.push(t.value);
189
+ } else if (t.kind === "digits") {
97
190
  if (!produced) {
98
- parts.push(signed);
191
+ if (isNegative && !negativeEmitted) {
192
+ parts.push("-");
193
+ negativeEmitted = true;
194
+ }
195
+ parts.push(formatted);
99
196
  produced = true;
100
197
  }
101
- // drop subsequent digit runs — they were part of the numeric
102
- // template we already substituted.
198
+ // Drop subsequent digit runs — single-section numeric templates emit
199
+ // one run. Multi-section formats (positive;negative;zero;text) are
200
+ // handled by C1's section splitter above, so two-run single-section
201
+ // formats are unusual; the first run governs intentionally.
103
202
  } else if (t.kind === "literal") {
104
203
  parts.push(t.value);
105
204
  } else if (t.kind === "percent") {
106
205
  parts.push("%");
107
- } else if (t.kind === "currency") {
108
- parts.push(t.value);
109
206
  }
110
207
  }
111
- if (!produced) parts.push(signed);
208
+ if (!produced) {
209
+ if (isNegative && !negativeEmitted) parts.push("-");
210
+ parts.push(formatted);
211
+ }
112
212
  return parts.join("");
113
213
  }
114
214
 
@@ -130,6 +230,10 @@ function formatScientific(value: number, code: string): string {
130
230
  const exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
131
231
  const mantissa = value / Math.pow(10, exp);
132
232
  const mantissaStr = mantissa.toFixed(Math.max(0, fracDigits));
233
+ // signToken "+" emits sign for both positive and negative exponents.
234
+ // signToken "" (no explicit sign) emits only "-" for negative exponents.
235
+ // Verified: "0.0E00" on 0.0001 → "1.0E-04" (the negative case emits "-"
236
+ // via the right branch of the ternary below).
133
237
  const sign = signToken === "+" ? (exp >= 0 ? "+" : "-") : exp < 0 ? "-" : "";
134
238
  const expStr = String(Math.abs(exp)).padStart(match[4]!.length, "0");
135
239
  return `${mantissaStr}E${sign}${expStr}`;