@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
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Plot-area layout: partition the canvas into title / legend / axis /
3
- * plot rectangles (Stage 3A, pure math).
3
+ * plot rectangles (Stage 3A + 3B update).
4
4
  *
5
5
  * Call shape:
6
6
  *
@@ -13,21 +13,23 @@
13
13
  * 4. Reserve X-axis band on the bottom.
14
14
  * 5. Remaining rectangle is the plot area.
15
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.
16
+ * Slice 3B: text measurement now uses the real `measureText` helper
17
+ * (`src/ui-tailwind/chart/render/font-metrics.ts`) which calls
18
+ * `ctx.measureText` in a browser and falls back to an empirical glyph-width
19
+ * table in SSR / Node test environments. The public `layoutPlotArea`
20
+ * signature is unchanged.
23
21
  */
24
22
 
25
23
  import type { ChartModel } from "../../../io/ooxml/chart/types.ts";
26
24
  import type { ResolvedTheme } from "../../../model/canonical-document.ts";
25
+ import { measureText } from "../render/font-metrics.ts";
26
+ import { formatNumber } from "../render/number-format.ts";
27
27
  import {
28
28
  generateCategoryTicks,
29
+ generateDateTicks,
29
30
  generateValueTicks,
30
31
  type TickResult,
32
+ type TimeUnit,
31
33
  } from "./axis-layout.ts";
32
34
 
33
35
  // ---------------------------------------------------------------------------
@@ -69,7 +71,6 @@ export function layoutPlotArea(
69
71
  model: ChartModel,
70
72
  theme: ResolvedTheme | undefined,
71
73
  ): PlotAreaLayout {
72
- void theme; // consumed by Slice 3B's real font-metrics wrapper.
73
74
 
74
75
  let top = 0;
75
76
  let bottom = canvas.h;
@@ -82,7 +83,7 @@ export function layoutPlotArea(
82
83
 
83
84
  // 1. Title band (top).
84
85
  if (model.kind !== "unsupported" && model.title && !model.title.overlay) {
85
- const titleHeight = measureTitleHeight(model.title.text ?? "");
86
+ const titleHeight = measureTitleHeight(model.title.text ?? "", theme);
86
87
  out.titleRect = { x: 0, y: 0, w: canvas.w, h: titleHeight };
87
88
  top += titleHeight + BAND_GAP_PX;
88
89
  }
@@ -90,7 +91,8 @@ export function layoutPlotArea(
90
91
  // 2. Legend band — on the side indicated by `legend.position`.
91
92
  if (model.kind !== "unsupported" && model.legend && !model.legend.overlay) {
92
93
  const entryCount = countLegendEntries(model);
93
- const { w: legendW, h: legendH } = measureLegendBox(entryCount);
94
+ const legendLabels = collectLegendLabels(model);
95
+ const { w: legendW, h: legendH } = measureLegendBox(entryCount, legendLabels, theme);
94
96
  switch (model.legend.position) {
95
97
  case "t":
96
98
  out.legendRect = { x: 0, y: top, w: canvas.w, h: legendH };
@@ -128,18 +130,19 @@ export function layoutPlotArea(
128
130
  const axes = pickAxes(model);
129
131
  if (axes) {
130
132
  // Y-axis on the left: width = max(tick label width) + axis-title
131
- // rotated height.
132
- const yTickLabels = axisTickLabels(axes.y);
133
+ // rotated height. Numeric tick values are passed through
134
+ // `formatNumber(value, formatCode)` so layout reserves real label width.
135
+ const yTickLabels = axisTickLabels(axes.y, axes.yFormatCode);
133
136
  const yWidth =
134
- maxLabelWidth(yTickLabels) + (axes.yTitle ? AXIS_TITLE_BAND_PX : 0);
137
+ maxLabelWidth(yTickLabels, theme) + (axes.yTitle ? AXIS_TITLE_BAND_PX : 0);
135
138
  out.yAxisRect = { x: left, y: top, w: yWidth, h: bottom - top };
136
139
  left += yWidth + BAND_GAP_PX;
137
140
 
138
141
  // Secondary Y-axis on the right.
139
142
  if (axes.secondaryY) {
140
- const y2TickLabels = axisTickLabels(axes.secondaryY);
143
+ const y2TickLabels = axisTickLabels(axes.secondaryY, axes.secondaryYFormatCode);
141
144
  const y2Width =
142
- maxLabelWidth(y2TickLabels) + (axes.secondaryYTitle ? AXIS_TITLE_BAND_PX : 0);
145
+ maxLabelWidth(y2TickLabels, theme) + (axes.secondaryYTitle ? AXIS_TITLE_BAND_PX : 0);
143
146
  out.secondaryYAxisRect = {
144
147
  x: right - y2Width,
145
148
  y: top,
@@ -150,7 +153,16 @@ export function layoutPlotArea(
150
153
  }
151
154
 
152
155
  // X-axis on the bottom: height = label height + axis-title band.
153
- const xLabelHeight = X_AXIS_LABEL_HEIGHT_PX;
156
+ // For category axes, sample the widest category label so tall rotated
157
+ // labels reserve enough vertical space; for value/date axes use a
158
+ // numeric sample run through formatNumber.
159
+ const xTickLabels = axes.x
160
+ ? axisTickLabels(axes.x, axes.xFormatCode)
161
+ : axes.categoryLabels
162
+ ? Array.from(axes.categoryLabels)
163
+ : yTickLabels;
164
+ const xSampleLabel = xTickLabels.length > 0 ? xTickLabels[0]! : "0";
165
+ const xLabelHeight = measureText(xSampleLabel, AXIS_TXP, theme).lineHeight;
154
166
  const xHeight = xLabelHeight + (axes.xTitle ? AXIS_TITLE_BAND_PX : 0);
155
167
  out.xAxisRect = {
156
168
  x: left,
@@ -171,57 +183,57 @@ export function layoutPlotArea(
171
183
  }
172
184
 
173
185
  // ---------------------------------------------------------------------------
174
- // Text measurement — fixed stub (Slice 3B replaces with real metrics)
186
+ // Text measurement — real metrics via font-metrics.ts (Slice 3B)
175
187
  // ---------------------------------------------------------------------------
176
188
 
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
189
  const BAND_GAP_PX = 4;
189
190
  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
191
  const LEGEND_SWATCH_WIDTH_PX = 16;
195
192
  const LEGEND_SWATCH_GAP_PX = 6;
196
193
  const LEGEND_ENTRY_GAP_PX = 12;
197
194
  const LEGEND_MIN_WIDTH_PX = 80;
198
195
 
199
- function measureTitleHeight(text: string): number {
196
+ const TITLE_TXP = { fontSizePt: 14 };
197
+ const AXIS_TXP = { fontSizePt: 10 };
198
+ const LEGEND_TXP = { fontSizePt: 10 };
199
+
200
+ function measureTitleHeight(text: string, theme: ResolvedTheme | undefined): number {
200
201
  if (!text) return 0;
201
- return DEFAULT_TITLE_FONT_PX * LINE_HEIGHT_RATIO;
202
+ return measureText(text, TITLE_TXP, theme).lineHeight;
202
203
  }
203
204
 
204
- function measureLegendBox(entryCount: number): { w: number; h: number } {
205
+ function measureLegendBox(
206
+ entryCount: number,
207
+ labels: ReadonlyArray<string>,
208
+ theme: ResolvedTheme | undefined,
209
+ ): { w: number; h: number } {
205
210
  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.
211
+ // Measure the widest label; fall back to an 8-char estimate when no labels.
212
+ const sampleLabels = labels.length > 0 ? labels : ["MMMMMMMM"];
213
+ let maxW = 0;
214
+ for (const label of sampleLabels) {
215
+ const w = measureText(label, LEGEND_TXP, theme).width;
216
+ if (w > maxW) maxW = w;
217
+ }
218
+ const entryWidth = LEGEND_SWATCH_WIDTH_PX + LEGEND_SWATCH_GAP_PX + maxW;
219
+ const lineH = measureText(sampleLabels[0]!, LEGEND_TXP, theme).lineHeight;
212
220
  return {
213
221
  w: Math.max(LEGEND_MIN_WIDTH_PX, entryWidth + LEGEND_ENTRY_GAP_PX),
214
- h: entryCount * DEFAULT_LEGEND_FONT_PX * LINE_HEIGHT_RATIO,
222
+ h: entryCount * lineH,
215
223
  };
216
224
  }
217
225
 
218
- function maxLabelWidth(labels: ReadonlyArray<string>): number {
226
+ function maxLabelWidth(
227
+ labels: ReadonlyArray<string>,
228
+ theme: ResolvedTheme | undefined,
229
+ ): number {
219
230
  if (labels.length === 0) return 0;
220
- let maxChars = 0;
231
+ let maxW = 0;
221
232
  for (const label of labels) {
222
- if (label.length > maxChars) maxChars = label.length;
233
+ const w = measureText(label, AXIS_TXP, theme).width;
234
+ if (w > maxW) maxW = w;
223
235
  }
224
- return maxChars * GLYPH_WIDTH_EM * DEFAULT_AXIS_FONT_PX;
236
+ return maxW;
225
237
  }
226
238
 
227
239
  // ---------------------------------------------------------------------------
@@ -230,11 +242,20 @@ function maxLabelWidth(labels: ReadonlyArray<string>): number {
230
242
 
231
243
  interface AxisBundle {
232
244
  y: TickResult;
245
+ yFormatCode?: string;
233
246
  yTitle: boolean;
234
247
  x?: TickResult;
248
+ xFormatCode?: string;
235
249
  xTitle: boolean;
236
250
  secondaryY?: TickResult;
251
+ secondaryYFormatCode?: string;
237
252
  secondaryYTitle: boolean;
253
+ /**
254
+ * Category axis labels (for bar/line/area/combo) — pre-resolved
255
+ * category-axis label strings. When present, the x-axis tick band
256
+ * measurement uses these strings rather than numeric tick indices.
257
+ */
258
+ categoryLabels?: ReadonlyArray<string>;
238
259
  }
239
260
 
240
261
  /**
@@ -254,23 +275,33 @@ function pickAxes(model: ChartModel): AxisBundle | null {
254
275
  const bundle: AxisBundle = {
255
276
  y: yTicks,
256
277
  yTitle: !!model.valueAxis.title,
257
- xTitle: model.categoryAxis.kind === "category"
258
- ? !!model.categoryAxis.title
259
- : !!model.categoryAxis.title,
278
+ x: axisTicks(model.categoryAxis),
279
+ xTitle: !!model.categoryAxis.title,
260
280
  secondaryYTitle: !!model.secondaryValueAxis?.title,
261
281
  };
282
+ if (model.valueAxis.numberFormat) bundle.yFormatCode = model.valueAxis.numberFormat;
283
+ if (model.categoryAxis.numberFormat) bundle.xFormatCode = model.categoryAxis.numberFormat;
262
284
  if (y2Ticks) bundle.secondaryY = y2Ticks;
285
+ if (model.secondaryValueAxis?.numberFormat) {
286
+ bundle.secondaryYFormatCode = model.secondaryValueAxis.numberFormat;
287
+ }
288
+ if (model.categoryAxis.kind === "category") {
289
+ bundle.categoryLabels = model.categoryAxis.categoryLabels;
290
+ }
263
291
  return bundle;
264
292
  }
265
293
  case "scatter":
266
294
  case "bubble": {
267
- return {
295
+ const bundle: AxisBundle = {
268
296
  y: axisTicks(model.yAxis),
269
297
  yTitle: !!model.yAxis.title,
270
298
  x: axisTicks(model.xAxis),
271
299
  xTitle: !!model.xAxis.title,
272
300
  secondaryYTitle: false,
273
301
  };
302
+ if (model.yAxis.numberFormat) bundle.yFormatCode = model.yAxis.numberFormat;
303
+ if (model.xAxis.numberFormat) bundle.xFormatCode = model.xAxis.numberFormat;
304
+ return bundle;
274
305
  }
275
306
  case "combo": {
276
307
  // Use the first group's axes as representative.
@@ -289,6 +320,10 @@ function axisTicks(axis: {
289
320
  min?: number;
290
321
  max?: number;
291
322
  majorUnit?: number;
323
+ majorTimeUnit?: TimeUnit;
324
+ minorUnit?: number;
325
+ minorTimeUnit?: TimeUnit;
326
+ baseTimeUnit?: TimeUnit;
292
327
  categoryLabels?: ReadonlyArray<string>;
293
328
  }): TickResult {
294
329
  if (axis.kind === "category") {
@@ -300,6 +335,20 @@ function axisTicks(axis: {
300
335
  minor: [],
301
336
  };
302
337
  }
338
+ // C8: DateAxis (kind === "date") must route to generateDateTicks so Excel
339
+ // serial dates produce properly-stepped tick positions rather than treating
340
+ // serials as raw numbers and calling generateValueTicks on them.
341
+ if (axis.kind === "date") {
342
+ return generateDateTicks({
343
+ min: axis.min ?? 0,
344
+ max: axis.max ?? 1,
345
+ ...(axis.majorUnit !== undefined ? { majorUnit: axis.majorUnit } : {}),
346
+ ...(axis.majorTimeUnit !== undefined ? { majorTimeUnit: axis.majorTimeUnit } : {}),
347
+ ...(axis.minorUnit !== undefined ? { minorUnit: axis.minorUnit } : {}),
348
+ ...(axis.minorTimeUnit !== undefined ? { minorTimeUnit: axis.minorTimeUnit } : {}),
349
+ ...(axis.baseTimeUnit !== undefined ? { baseTimeUnit: axis.baseTimeUnit } : {}),
350
+ });
351
+ }
303
352
  const min = axis.min ?? 0;
304
353
  const max = axis.max ?? 1;
305
354
  return generateValueTicks({
@@ -310,14 +359,25 @@ function axisTicks(axis: {
310
359
  }
311
360
 
312
361
  /**
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).
362
+ * Produce string labels for an axis's major ticks.
363
+ *
364
+ * - Value / date axes: each tick numeric value is passed through
365
+ * `formatNumber(value, formatCode)` so layout reserves width for the
366
+ * real rendered string (e.g. `"$1,234"`, `"45%"`, `"Jan-24"`) not the
367
+ * raw `String(1234)` approximation.
368
+ * - Category axes: when categoryLabels are provided, return them verbatim
369
+ * (tick indices 0..N-1 map 1:1 to labels). This avoids reserving space
370
+ * for "0", "1", "2" when the real rendered label is "Q1", "Q2", "Q3".
318
371
  */
319
- function axisTickLabels(ticks: TickResult): string[] {
320
- return ticks.major.map((t) => String(t));
372
+ function axisTickLabels(
373
+ ticks: TickResult,
374
+ formatCode: string | undefined,
375
+ categoryLabels?: ReadonlyArray<string>,
376
+ ): string[] {
377
+ if (categoryLabels && categoryLabels.length > 0) {
378
+ return ticks.major.map((i) => categoryLabels[i] ?? "");
379
+ }
380
+ return ticks.major.map((t) => formatNumber(t, formatCode));
321
381
  }
322
382
 
323
383
  function countLegendEntries(model: ChartModel): number {
@@ -329,7 +389,9 @@ function countLegendEntries(model: ChartModel): number {
329
389
  case "bubble":
330
390
  return model.series.length;
331
391
  case "pie": {
332
- // Pie legends show one entry per slice.
392
+ // Pie legends show one entry per slice. Prefer categoryLabels when
393
+ // resolved; fall back to series value count for degenerate inputs.
394
+ if (model.categoryLabels.length > 0) return model.categoryLabels.length;
333
395
  const first = model.series[0];
334
396
  return first ? first.values.length : 0;
335
397
  }
@@ -342,3 +404,34 @@ function countLegendEntries(model: ChartModel): number {
342
404
  return 0;
343
405
  }
344
406
  }
407
+
408
+ function collectLegendLabels(model: ChartModel): ReadonlyArray<string> {
409
+ switch (model.kind) {
410
+ case "bar":
411
+ case "line":
412
+ case "area":
413
+ case "scatter":
414
+ case "bubble":
415
+ return model.series.map((s) => s.name ?? `Series ${s.idx + 1}`);
416
+ case "pie": {
417
+ // PieChartModel exposes categoryLabels at the model level (resolved
418
+ // from the first series' c:cat cache during parsing). Each slice
419
+ // in the legend corresponds 1:1 to a category label. Fall back to
420
+ // synthetic "Slice N" only when the parser saw no category cache
421
+ // (degenerate data-only DOCX).
422
+ if (model.categoryLabels.length > 0) return model.categoryLabels;
423
+ const first = model.series[0];
424
+ if (!first) return [];
425
+ return first.values.map((_, i) => `Slice ${i + 1}`);
426
+ }
427
+ case "combo": {
428
+ const labels: string[] = [];
429
+ for (const g of model.groups) {
430
+ for (const s of g.series) labels.push(s.name ?? `Series ${s.idx + 1}`);
431
+ }
432
+ return labels;
433
+ }
434
+ case "unsupported":
435
+ return [];
436
+ }
437
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Title layout — chart title + axis titles (Stage 3C).
3
+ *
4
+ * Chart title:
5
+ * - Centered horizontally within the reserved title band.
6
+ * - Vertical baseline respects the title font's ascent/descent.
7
+ * - Rich text (c:tx/c:rich) is not yet unpacked — the title.text
8
+ * string from the parser is treated as the full title. Rich runs
9
+ * land in Stage 4G alongside data labels.
10
+ *
11
+ * Axis titles:
12
+ * - Y-axis: rotated −90° (anti-clockwise). Title sits in the leftmost
13
+ * column of the y-axis band, vertically centered on the plot area.
14
+ * - Secondary y-axis: rotated +90° so the baseline reads top-to-bottom
15
+ * on the right side. Title sits in the rightmost column of the
16
+ * secondary y-axis band.
17
+ * - X-axis: no rotation. Title sits below the tick-label band,
18
+ * horizontally centered on the plot area.
19
+ *
20
+ * The helpers return text-placement descriptors (x/y position, font
21
+ * size, rotation, anchor) rather than SVG elements — renderers compose
22
+ * these via `svgText` from `svg-primitives.ts`.
23
+ */
24
+
25
+ import type { Rect } from "./plot-area.ts";
26
+ import type { ResolvedTheme } from "../../../model/canonical-document.ts";
27
+ import { measureText } from "../render/font-metrics.ts";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Public API
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export type AxisTitleKind = "x" | "y" | "secondaryY";
34
+
35
+ export interface TitlePlacement {
36
+ text: string;
37
+ x: number;
38
+ y: number;
39
+ fontSizePt: number;
40
+ bold: boolean;
41
+ /** SVG rotation in degrees, clockwise. 0 = upright. */
42
+ rotate: number;
43
+ /** SVG text-anchor: where the (x,y) anchor point lies on the glyph run. */
44
+ anchor: "start" | "middle" | "end";
45
+ /** Vertical alignment of the (x,y) point to the glyph row. */
46
+ baseline: "hanging" | "middle" | "auto";
47
+ }
48
+
49
+ export interface TitleLayoutOptions {
50
+ /** Font size in points. Word default: 14pt for chart title, 10pt for axis titles. */
51
+ fontSizePt?: number;
52
+ /** Whether the title is rendered in bold. Word chart title default: true. */
53
+ bold?: boolean;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Chart title
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Place a chart title centered horizontally inside `titleRect`. The
62
+ * returned placement anchors at the middle of the title band and uses
63
+ * `text-anchor: middle` so the glyph run spans equally on each side.
64
+ *
65
+ * Returns `null` when text is empty — callers should not emit a title
66
+ * element at all in that case.
67
+ */
68
+ export function layoutTitle(
69
+ text: string,
70
+ titleRect: Rect,
71
+ theme: ResolvedTheme | undefined,
72
+ options: TitleLayoutOptions = {},
73
+ ): TitlePlacement | null {
74
+ if (!text) return null;
75
+ const fontSizePt = options.fontSizePt ?? 14;
76
+ const bold = options.bold ?? true;
77
+ // Consume theme for future font-fallback resolution.
78
+ void theme;
79
+ const centerX = titleRect.x + titleRect.w / 2;
80
+ const centerY = titleRect.y + titleRect.h / 2;
81
+ return {
82
+ text,
83
+ x: centerX,
84
+ y: centerY,
85
+ fontSizePt,
86
+ bold,
87
+ rotate: 0,
88
+ anchor: "middle",
89
+ baseline: "middle",
90
+ };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Axis titles
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Place an axis title inside the axis-title band. The `kind` determines
99
+ * rotation:
100
+ * - `"y"` → −90° (baseline reads bottom-to-top on the left)
101
+ * - `"secondaryY"` → +90° (baseline reads top-to-bottom on the right)
102
+ * - `"x"` → 0° (no rotation)
103
+ *
104
+ * `bandRect` is the rectangle the plot-area reserved for the axis title
105
+ * itself (NOT the full axis band — just the title sliver). For y / sY
106
+ * this is typically ~14px wide (the `AXIS_TITLE_BAND_PX` constant from
107
+ * plot-area.ts); for x it's ~14px tall.
108
+ *
109
+ * `plotRect` is the plot area the title labels. The title is visually
110
+ * centered against the plot area (not the band) so it aligns with the
111
+ * mid-point of the axis data range.
112
+ */
113
+ export function layoutAxisTitle(
114
+ text: string,
115
+ kind: AxisTitleKind,
116
+ bandRect: Rect,
117
+ plotRect: Rect,
118
+ theme: ResolvedTheme | undefined,
119
+ options: TitleLayoutOptions = {},
120
+ ): TitlePlacement | null {
121
+ if (!text) return null;
122
+ const fontSizePt = options.fontSizePt ?? 10;
123
+ const bold = options.bold ?? false;
124
+
125
+ switch (kind) {
126
+ case "y": {
127
+ // Vertical center on the plot, rotated -90°. Anchor at band center x.
128
+ // Consume theme to keep signature future-proof.
129
+ void theme;
130
+ return {
131
+ text,
132
+ x: bandRect.x + bandRect.w / 2,
133
+ y: plotRect.y + plotRect.h / 2,
134
+ fontSizePt,
135
+ bold,
136
+ rotate: -90,
137
+ anchor: "middle",
138
+ baseline: "middle",
139
+ };
140
+ }
141
+ case "secondaryY": {
142
+ return {
143
+ text,
144
+ x: bandRect.x + bandRect.w / 2,
145
+ y: plotRect.y + plotRect.h / 2,
146
+ fontSizePt,
147
+ bold,
148
+ rotate: 90,
149
+ anchor: "middle",
150
+ baseline: "middle",
151
+ };
152
+ }
153
+ case "x": {
154
+ return {
155
+ text,
156
+ x: plotRect.x + plotRect.w / 2,
157
+ y: bandRect.y + bandRect.h / 2,
158
+ fontSizePt,
159
+ bold,
160
+ rotate: 0,
161
+ anchor: "middle",
162
+ baseline: "middle",
163
+ };
164
+ }
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Measurement helper for band sizing
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Measure the title height for the given text + font size, matching the
174
+ * row height a single-line title would occupy. Callers can use this to
175
+ * validate `titleRect.h` was reserved adequately before rendering.
176
+ */
177
+ export function measureTitleBandHeight(
178
+ text: string,
179
+ fontSizePt: number,
180
+ theme: ResolvedTheme | undefined,
181
+ ): number {
182
+ if (!text) return 0;
183
+ return measureText(text, { fontSizePt, bold: true }, theme).lineHeight;
184
+ }